diff --git a/src/app/api/import/apple-health-export/__tests__/route.test.ts b/src/app/api/import/apple-health-export/__tests__/route.test.ts index 8acfc3b04..fb762f7fa 100644 --- a/src/app/api/import/apple-health-export/__tests__/route.test.ts +++ b/src/app/api/import/apple-health-export/__tests__/route.test.ts @@ -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()), + 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 () => { diff --git a/src/app/api/import/apple-health-export/route.ts b/src/app/api/import/apple-health-export/route.ts index eb3c81f50..20eb69061 100644 --- a/src/app/api/import/apple-health-export/route.ts +++ b/src/app/api/import/apple-health-export/route.ts @@ -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); diff --git a/src/app/api/import/health-connect-export/route.ts b/src/app/api/import/health-connect-export/route.ts index 04177f556..d8eb2b6b8 100644 --- a/src/app/api/import/health-connect-export/route.ts +++ b/src/app/api/import/health-connect-export/route.ts @@ -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");