mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
fix(import): log a refused upload as a signal, not an audit row
An upload refused for its size, a broken stream or a stopped worker is an operational outcome, not something done to the account. The admin import keeps its audit rows, since an admin acting on another account is.
This commit is contained in:
1 parent
689796db33
commit
b12bece491
3 files changed
+46
-26
No files matched your search
@@ -14,6 +14,10 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/logging/signal", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/logging/signal")>()),
|
||||
emitSignal: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
@@ -78,6 +82,8 @@ vi.mock("@/lib/db", () => ({
|
||||
|
||||
import { POST } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { emitSignal } from "@/lib/logging/signal";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -144,6 +150,18 @@ describe("POST /api/import/apple-health-export", () => {
|
||||
);
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(413);
|
||||
// A refused upload is an operational outcome, not an account action: a
|
||||
// signal, never an audit row.
|
||||
expect(auditLog).not.toHaveBeenCalledWith(
|
||||
"import.apple-health.kickoff.denied",
|
||||
expect.anything(),
|
||||
);
|
||||
expect(emitSignal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "import.apple-health.kickoff.denied",
|
||||
meta: expect.objectContaining({ reason: "content_length_exceeded" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 429 when the rate-limit window is exhausted", async () => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { emitSignal } from "@/lib/logging/signal";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import {
|
||||
@@ -77,12 +78,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// content-length cannot bypass it.
|
||||
const declaredBytes = Number(request.headers.get("content-length") ?? 0);
|
||||
if (declaredBytes > MAX_UPLOAD_BYTES) {
|
||||
await auditLog("import.apple-health.kickoff.denied", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
emitSignal({
|
||||
action: "import.apple-health.kickoff.denied",
|
||||
level: "warn",
|
||||
meta: {
|
||||
reason: "content_length_exceeded",
|
||||
contentLength: declaredBytes,
|
||||
content_length: declaredBytes,
|
||||
},
|
||||
});
|
||||
return apiError("Upload exceeds 1.5 GB limit", 413);
|
||||
@@ -107,10 +108,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "upload_failed";
|
||||
await auditLog("import.apple-health.kickoff.denied", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { reason: "stream_to_disk_failed", message },
|
||||
emitSignal({
|
||||
action: "import.apple-health.kickoff.denied",
|
||||
level: "warn",
|
||||
meta: { reason: "stream_to_disk_failed", message },
|
||||
});
|
||||
return apiError(`Multipart upload failed: ${message}`, 422);
|
||||
}
|
||||
@@ -150,10 +151,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) {
|
||||
await auditLog("import.apple-health.kickoff.denied", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { reason: "worker_not_running" },
|
||||
emitSignal({
|
||||
action: "import.apple-health.kickoff.denied",
|
||||
level: "warn",
|
||||
meta: { reason: "worker_not_running" },
|
||||
});
|
||||
// Nothing will ever read the staged export; it must not stay in /tmp.
|
||||
await discardStagedUpload(uploaded.filePath);
|
||||
|
||||
@@ -22,6 +22,7 @@ import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { emitSignal } from "@/lib/logging/signal";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import {
|
||||
@@ -67,12 +68,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const declaredBytes = Number(request.headers.get("content-length") ?? 0);
|
||||
if (declaredBytes > HEALTH_CONNECT_MAX_UPLOAD_BYTES) {
|
||||
await auditLog("import.health-connect.kickoff.denied", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
emitSignal({
|
||||
action: "import.health-connect.kickoff.denied",
|
||||
level: "warn",
|
||||
meta: {
|
||||
reason: "content_length_exceeded",
|
||||
contentLength: declaredBytes,
|
||||
content_length: declaredBytes,
|
||||
},
|
||||
});
|
||||
return apiError("Upload exceeds the 1 GB limit", 413);
|
||||
@@ -94,10 +95,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "upload_failed";
|
||||
await auditLog("import.health-connect.kickoff.denied", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { reason: "stream_to_disk_failed" },
|
||||
emitSignal({
|
||||
action: "import.health-connect.kickoff.denied",
|
||||
level: "warn",
|
||||
meta: { reason: "stream_to_disk_failed" },
|
||||
});
|
||||
return apiError(`Multipart upload failed: ${message}`, 422);
|
||||
}
|
||||
@@ -123,10 +124,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) {
|
||||
await auditLog("import.health-connect.kickoff.denied", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { reason: "worker_not_running" },
|
||||
emitSignal({
|
||||
action: "import.health-connect.kickoff.denied",
|
||||
level: "warn",
|
||||
meta: { reason: "worker_not_running" },
|
||||
});
|
||||
await discardStagedUpload(uploaded.filePath);
|
||||
throw new HttpError(503, "Background worker is not running");
|
||||
|
||||
Reference in new issue
Block a user