mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
chore(release): v1.4.49 — audit-backlog closure + server reminder suppression + diagnostic endpoint + analytics cold-path fix
Bundles the deferred v1.4.47 Mediums + Lows with the v1.4.48-deferred items into a single release. Headline: server-side suppression of MEDICATION_REMINDER APNs for iOS clients that own their local reminders (closes GH #206), a push_attempts table backing /api/admin/notifications/diagnostic, and a sweep of v1.4.48 QA forward-findings. Performance (the user-visible win): - /api/analytics cold-fallback path splits the heavy single-statement aggregate into two parallel queries. The linearly composable columns (count / min / max / mean) keep the full-partition scan; the windowed 7 / 30 / 90-day avg / slope / r² columns take a 90-day outer cap so the planner does an index range scan on (user_id, type, measured_at) instead of a full-partition sequential scan. Output is bit-identical; cold slim slice drops from multi-second to sub-second on tenants with large measurement partitions. Server-side notifications: - GET + PATCH /api/auth/me/notification-prefs with deep-merge semantics. Initial category medication.clientManaged: boolean (default false). Cron medicationReminderJob skips dose-due APNs when the flag is true, emitting a medication_reminder.suppressed_client_managed wide-event annotation per skip. - push_attempts table backs the diagnostic endpoint. Every APNS / Web-Push / Telegram / NTFY sender writes a fire-and-forget row. Daily 03:35 Europe/Berlin cleanup prunes rows older than 90 days. - /api/admin/notifications/diagnostic — admin-only endpoint surfacing device APNs token presence (masked), channel config presence, and recent push attempts. Registered in OpenAPI. - Per-channel APNs "Send test" button now fires with eventType: "MEDICATION_REMINDER" so the dispatcher's time-sensitive + priority-10 branch runs. A successful self-test now exercises the exact path production reminders take; an unsuccessful one points the user at iOS Settings → Time Sensitive Notifications rather than appearing to be a server-side bug. Withings reliability: - OAuth callback consumes the nonce atomically (delete-first with P2025 catch for the replay branch). The previous read-then-delete pattern allowed a race between two concurrent callbacks. - ?reason=state redirect differentiated into csrf1 / replay / expired / cross_user so operators can tell mismatch types apart without DB shell access. - connect endpoint rate-limited to 10/60s/user; row-create failures redirect to a consistent error surface. Observability hardening: - redactSensitiveFields(body) denylist helper covers password / token / secret / apiKey / authorization / csrfState / nonce, recursive, case-insensitive. Caught: /api/devices invalid_format issues were echoing apnsToken values verbatim into the audit log. - sanitiseZodIssues(issues, { stripValuesFromMessage: true }) overload applied to 14 audit-log sites. Migrations: 0079 (notification_prefs), 0080 (push_attempts). Tests: 4815 → 5272 unit + integration. Workout-batch integration suite that had been red on main since v1.4.45 (three release commits shipped through it) now green again — updated to match v1.4.42 cross-source dedup semantics. Resolves PR #207.
This commit is contained in:
1 parent
30ab2112db
commit
f92e644154
87 files changed
+7939
-2950
No files matched your search
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* v1.4.48 H-APNs-1 — coverage for the admin notification diagnostic
|
||||
* endpoint. The route surfaces what the dispatcher would observe when
|
||||
* targeting the calling admin's account so an operator can debug push
|
||||
* delivery without DB-shell access; these tests pin:
|
||||
*
|
||||
* * 401 for an unauthenticated caller (no session cookie),
|
||||
* * 403 for an authenticated non-admin (USER role),
|
||||
* * 200 + the documented envelope shape for an admin with an empty
|
||||
* device + channel set,
|
||||
* * APNs token masking (prefix 8 + suffix 8 hex only, full token
|
||||
* never returned),
|
||||
* * per-channel `configPresent` reflecting the decrypted JSON blob
|
||||
* for TELEGRAM / NTFY and the row-existence shortcut for APNS /
|
||||
* WEB_PUSH (the latter two store delivery targets on sibling
|
||||
* tables, not in the channel config blob).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
device: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
notificationChannel: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
// v1.4.49 — backing query for `recentPushAttempts`. The route
|
||||
// pulls the trailing 20 rows ordered by `createdAt` DESC and maps
|
||||
// the `createdAt` Date to an `at` ISO string for the response.
|
||||
pushAttempt: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// The route imports requireAdmin from `@/lib/api-handler`. We keep
|
||||
// apiHandler real so HttpError throws produce the proper 401 / 403
|
||||
// JSON envelope, but stub requireAdmin to drive the three auth cases.
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
eventStorage: {
|
||||
run: <T>(_evt: unknown, fn: () => Promise<T>) => fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({
|
||||
emitIfSampled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((value: string) => value.replace(/^enc\(|\)$/g, "")),
|
||||
encrypt: vi.fn((value: string) => `enc(${value})`),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
|
||||
const ADMIN_USER_ID = "admin-1";
|
||||
const ADMIN_CTX = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: ADMIN_USER_ID,
|
||||
username: "admin",
|
||||
role: "ADMIN",
|
||||
},
|
||||
} as never;
|
||||
|
||||
interface DiagnosticEnvelope {
|
||||
data: {
|
||||
devices: Array<{
|
||||
id: string;
|
||||
platform: string;
|
||||
hasApnsToken: boolean;
|
||||
apnsTokenPrefix: string | null;
|
||||
apnsTokenSuffix: string | null;
|
||||
apnsEnvironment: string | null;
|
||||
lastSeenAt: string;
|
||||
}>;
|
||||
notificationChannels: Array<{
|
||||
type: string;
|
||||
enabled: boolean;
|
||||
configPresent: boolean;
|
||||
}>;
|
||||
recentPushAttempts: Array<{
|
||||
eventType: string;
|
||||
channel: string;
|
||||
result: string;
|
||||
reason: string | null;
|
||||
at: string;
|
||||
}>;
|
||||
};
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(prisma.device.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.pushAttempt.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
describe("GET /api/admin/notifications/diagnostic — auth gates", () => {
|
||||
it("returns 401 when the caller has no session", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(401, "Not authenticated"),
|
||||
);
|
||||
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(401);
|
||||
const body = (await res.json()) as { data: null; error: string };
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Not authenticated");
|
||||
});
|
||||
|
||||
it("returns 403 when the caller is authenticated but not ADMIN", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(403, "Admin access required"),
|
||||
);
|
||||
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as { data: null; error: string };
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Admin access required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/admin/notifications/diagnostic — empty state", () => {
|
||||
it("returns the documented envelope shape with empty arrays for an admin with no devices or channels", async () => {
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as DiagnosticEnvelope;
|
||||
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.data).toEqual({
|
||||
devices: [],
|
||||
notificationChannels: [],
|
||||
recentPushAttempts: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/admin/notifications/diagnostic — device masking", () => {
|
||||
it("returns hasApnsToken=true and 8/8-char prefix+suffix when device has an APNs token", async () => {
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
const fullToken =
|
||||
"abcdef1234567890aabbccddeeff0011223344556677889900112233445566778899aabb";
|
||||
// Trim to a typical 64-char APNs hex token to keep the assertion
|
||||
// realistic. The masker doesn't care about length beyond `>= 16`.
|
||||
const token64 = fullToken.slice(0, 64);
|
||||
vi.mocked(prisma.device.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "dev-1",
|
||||
platform: "ios",
|
||||
apnsToken: token64,
|
||||
apnsEnvironment: "production",
|
||||
lastSeen: new Date("2026-05-22T10:00:00.000Z"),
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as DiagnosticEnvelope;
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.devices).toHaveLength(1);
|
||||
const device = body.data.devices[0];
|
||||
expect(device.hasApnsToken).toBe(true);
|
||||
expect(device.apnsTokenPrefix).toBe(token64.slice(0, 8));
|
||||
expect(device.apnsTokenSuffix).toBe(token64.slice(-8));
|
||||
expect(device.apnsEnvironment).toBe("production");
|
||||
expect(device.lastSeenAt).toBe("2026-05-22T10:00:00.000Z");
|
||||
|
||||
// Belt-and-braces: the full token must not appear anywhere in the
|
||||
// serialised response.
|
||||
const serialised = JSON.stringify(body);
|
||||
expect(serialised).not.toContain(token64);
|
||||
});
|
||||
|
||||
it("returns hasApnsToken=false and null prefix/suffix when the device row has no APNs token", async () => {
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
vi.mocked(prisma.device.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "dev-2",
|
||||
platform: "ios",
|
||||
apnsToken: null,
|
||||
apnsEnvironment: null,
|
||||
lastSeen: new Date("2026-05-22T11:00:00.000Z"),
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as DiagnosticEnvelope;
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const device = body.data.devices[0];
|
||||
expect(device.hasApnsToken).toBe(false);
|
||||
expect(device.apnsTokenPrefix).toBeNull();
|
||||
expect(device.apnsTokenSuffix).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/admin/notifications/diagnostic — channel configPresent", () => {
|
||||
it("returns configPresent=true for TELEGRAM when the decrypted blob has both botToken and chatId", async () => {
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
|
||||
{
|
||||
type: "TELEGRAM",
|
||||
enabled: true,
|
||||
config: 'enc({"botToken":"bot:12345","chatId":"678"})',
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as DiagnosticEnvelope;
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.notificationChannels).toEqual([
|
||||
{ type: "TELEGRAM", enabled: true, configPresent: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns configPresent=false for TELEGRAM when chatId is empty", async () => {
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
|
||||
{
|
||||
type: "TELEGRAM",
|
||||
enabled: false,
|
||||
config: 'enc({"botToken":"bot:12345","chatId":""})',
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as DiagnosticEnvelope;
|
||||
|
||||
expect(body.data.notificationChannels).toEqual([
|
||||
{ type: "TELEGRAM", enabled: false, configPresent: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns configPresent=true for NTFY when topic is non-empty", async () => {
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
|
||||
{
|
||||
type: "NTFY",
|
||||
enabled: true,
|
||||
config: 'enc({"serverUrl":"https://ntfy.sh","topic":"hl-test"})',
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as DiagnosticEnvelope;
|
||||
|
||||
expect(body.data.notificationChannels).toEqual([
|
||||
{ type: "NTFY", enabled: true, configPresent: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns configPresent=false for NTFY when topic is missing", async () => {
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
|
||||
{
|
||||
type: "NTFY",
|
||||
enabled: true,
|
||||
config: 'enc({"serverUrl":"https://ntfy.sh"})',
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as DiagnosticEnvelope;
|
||||
|
||||
expect(body.data.notificationChannels).toEqual([
|
||||
{ type: "NTFY", enabled: true, configPresent: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats APNS and WEB_PUSH rows as configPresent=true (delivery target lives on sibling tables)", async () => {
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
|
||||
{ type: "APNS", enabled: true, config: "enc({})" },
|
||||
{ type: "WEB_PUSH", enabled: false, config: "enc({})" },
|
||||
] as never);
|
||||
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as DiagnosticEnvelope;
|
||||
|
||||
expect(body.data.notificationChannels).toEqual([
|
||||
{ type: "APNS", enabled: true, configPresent: true },
|
||||
{ type: "WEB_PUSH", enabled: false, configPresent: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns configPresent=false when the encrypted blob is malformed (decryption throws)", async () => {
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
// Our crypto mock unwraps `enc(...)`; pass a value that yields
|
||||
// non-JSON after the unwrap so JSON.parse throws and the helper's
|
||||
// try/catch returns false.
|
||||
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
|
||||
{ type: "TELEGRAM", enabled: true, config: "not-encrypted-junk" },
|
||||
] as never);
|
||||
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as DiagnosticEnvelope;
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.notificationChannels).toEqual([
|
||||
{ type: "TELEGRAM", enabled: true, configPresent: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/admin/notifications/diagnostic — recentPushAttempts", () => {
|
||||
it("returns recentPushAttempts as an empty array when the user has no rows", async () => {
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as DiagnosticEnvelope;
|
||||
|
||||
expect(body.data.recentPushAttempts).toEqual([]);
|
||||
});
|
||||
|
||||
it("maps push_attempts rows to the documented response shape (createdAt → at ISO string)", async () => {
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
// The route's findMany select returns `createdAt` as a Date; the
|
||||
// mapping step normalises to an ISO string under `at`.
|
||||
vi.mocked(prisma.pushAttempt.findMany).mockResolvedValue([
|
||||
{
|
||||
eventType: "MEDICATION_REMINDER",
|
||||
channel: "APNS",
|
||||
result: "ok",
|
||||
reason: null,
|
||||
createdAt: new Date("2026-05-22T09:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
eventType: "MOOD_REMINDER",
|
||||
channel: "TELEGRAM",
|
||||
result: "error",
|
||||
reason: "telegram_blocked_by_user",
|
||||
createdAt: new Date("2026-05-22T08:30:00.000Z"),
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as DiagnosticEnvelope;
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.recentPushAttempts).toEqual([
|
||||
{
|
||||
eventType: "MEDICATION_REMINDER",
|
||||
channel: "APNS",
|
||||
result: "ok",
|
||||
reason: null,
|
||||
at: "2026-05-22T09:00:00.000Z",
|
||||
},
|
||||
{
|
||||
eventType: "MOOD_REMINDER",
|
||||
channel: "TELEGRAM",
|
||||
result: "error",
|
||||
reason: "telegram_blocked_by_user",
|
||||
at: "2026-05-22T08:30:00.000Z",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("scopes the push-attempt query to the calling admin's userId, ordered desc by recency, capped at 20", async () => {
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
vi.mocked(prisma.pushAttempt.findMany).mockResolvedValue([] as never);
|
||||
|
||||
await GET();
|
||||
|
||||
expect(vi.mocked(prisma.pushAttempt.findMany)).toHaveBeenCalledTimes(1);
|
||||
const args = vi.mocked(prisma.pushAttempt.findMany).mock.calls[0]?.[0] as {
|
||||
where: { userId: string };
|
||||
orderBy: { createdAt: "desc" };
|
||||
take: number;
|
||||
};
|
||||
expect(args.where.userId).toBe(ADMIN_USER_ID);
|
||||
expect(args.orderBy).toEqual({ createdAt: "desc" });
|
||||
expect(args.take).toBe(20);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import type { ChannelType } from "@/lib/notifications/types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* v1.4.48 H-APNs-1 — admin-only diagnostic endpoint for the
|
||||
* notification subsystem. Surfaces what the dispatcher would see when
|
||||
* targeting the calling admin's account so an operator can debug an
|
||||
* iOS / Web Push / Telegram / ntfy issue without needing DB shell
|
||||
* access. This fills the gap that bottlenecked the v1.4.47 APNs hunt
|
||||
* — every "is the token there?" / "is the channel enabled?" question
|
||||
* was a Coolify exec round-trip.
|
||||
*
|
||||
* Response shape:
|
||||
* {
|
||||
* devices: Device[], — APNs token presence + masked
|
||||
* prefix/suffix only; never the
|
||||
* full token.
|
||||
* notificationChannels: Channel[] — per-channel enabled + whether
|
||||
* the channel-specific config
|
||||
* blob is present.
|
||||
* recentPushAttempts: PushAttempt[] — last 20 push deliveries for
|
||||
* the calling user, ordered by
|
||||
* recency. Backed by the
|
||||
* `push_attempts` table — every
|
||||
* sender (APNS, WEB_PUSH,
|
||||
* TELEGRAM, NTFY) writes one
|
||||
* fire-and-forget row per
|
||||
* dispatch. v1.4.49 closed the
|
||||
* empty-array stub by adding
|
||||
* the table + the write path.
|
||||
* }
|
||||
*
|
||||
* Security: admin-only (cookie auth, never Bearer). The route never
|
||||
* returns the full APNs token — only an 8-char hex prefix + 8-char
|
||||
* suffix so the operator can correlate with iOS-side diagnostics.
|
||||
*/
|
||||
|
||||
interface DiagnosticDevice {
|
||||
id: string;
|
||||
platform: string;
|
||||
hasApnsToken: boolean;
|
||||
apnsTokenPrefix: string | null;
|
||||
apnsTokenSuffix: string | null;
|
||||
apnsEnvironment: string | null;
|
||||
lastSeenAt: string;
|
||||
}
|
||||
|
||||
interface DiagnosticChannel {
|
||||
type: ChannelType | string;
|
||||
enabled: boolean;
|
||||
configPresent: boolean;
|
||||
}
|
||||
|
||||
interface DiagnosticPushAttempt {
|
||||
eventType: string;
|
||||
channel: string;
|
||||
result: string;
|
||||
reason: string | null;
|
||||
at: string;
|
||||
}
|
||||
|
||||
interface DiagnosticPayload {
|
||||
devices: DiagnosticDevice[];
|
||||
notificationChannels: DiagnosticChannel[];
|
||||
recentPushAttempts: DiagnosticPushAttempt[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an APNs hex token to its 8-char prefix + 8-char suffix. APNs
|
||||
* device tokens are 64-char hex strings; we surface 8 + 8 = 16 chars
|
||||
* total (25 % of the token), which is enough for the operator to
|
||||
* cross-check against an iOS-side log line without disclosing the
|
||||
* delivery target. Tokens shorter than 16 chars are treated as opaque
|
||||
* (prefix / suffix both null) — that shape never appears in
|
||||
* production, but defensive against partial inserts during testing.
|
||||
*/
|
||||
function maskApnsToken(
|
||||
token: string | null,
|
||||
): { prefix: string | null; suffix: string | null } {
|
||||
if (!token || token.length < 16) return { prefix: null, suffix: null };
|
||||
return {
|
||||
prefix: token.slice(0, 8),
|
||||
suffix: token.slice(-8),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the channel's encrypted-JSON config blob carries the
|
||||
* key the corresponding sender will actually read. We decrypt + parse
|
||||
* inside a try/catch so a corrupted ciphertext row (or an upstream
|
||||
* encryption-key rotation) cannot 500 the diagnostic endpoint — the
|
||||
* worst case is `configPresent: false` and the operator investigates
|
||||
* why the row is unreadable.
|
||||
*
|
||||
* The required field per channel type:
|
||||
* - TELEGRAM → `chatId` + `botToken`
|
||||
* - NTFY → `topic`
|
||||
* - WEB_PUSH → no per-channel blob (subscriptions live in the
|
||||
* `push_subscriptions` table); treat the row's mere
|
||||
* existence as `configPresent: true`.
|
||||
* - APNS → no per-channel blob (device tokens live on the
|
||||
* `devices` table); treat the row's mere existence as
|
||||
* `configPresent: true`.
|
||||
*/
|
||||
function isChannelConfigPresent(
|
||||
type: string,
|
||||
encryptedConfig: string,
|
||||
): boolean {
|
||||
if (type === "WEB_PUSH" || type === "APNS") {
|
||||
// These channels store their delivery target on sibling tables
|
||||
// (`push_subscriptions` / `devices`), not in the channel-row
|
||||
// config blob. The row's existence is the signal.
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(decrypt(encryptedConfig)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (type === "TELEGRAM") {
|
||||
return (
|
||||
typeof parsed.chatId === "string" &&
|
||||
parsed.chatId.length > 0 &&
|
||||
typeof parsed.botToken === "string" &&
|
||||
parsed.botToken.length > 0
|
||||
);
|
||||
}
|
||||
if (type === "NTFY") {
|
||||
return typeof parsed.topic === "string" && parsed.topic.length > 0;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.notifications.diagnostic" } });
|
||||
|
||||
const [devices, channels, pushAttempts] = await Promise.all([
|
||||
prisma.device.findMany({
|
||||
where: { userId: user.id },
|
||||
select: {
|
||||
id: true,
|
||||
platform: true,
|
||||
apnsToken: true,
|
||||
apnsEnvironment: true,
|
||||
lastSeen: true,
|
||||
},
|
||||
orderBy: { lastSeen: "desc" },
|
||||
}),
|
||||
prisma.notificationChannel.findMany({
|
||||
where: { userId: user.id },
|
||||
select: { type: true, enabled: true, config: true },
|
||||
}),
|
||||
// v1.4.49 — trailing 20 push attempts for the calling admin's own
|
||||
// account. The `(user_id, created_at DESC)` index on `push_attempts`
|
||||
// turns the orderBy + take into a single index scan, so the cost is
|
||||
// bounded regardless of the user's lifetime push volume.
|
||||
prisma.pushAttempt.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 20,
|
||||
select: {
|
||||
eventType: true,
|
||||
channel: true,
|
||||
result: true,
|
||||
reason: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const diagnosticDevices: DiagnosticDevice[] = devices.map((d) => {
|
||||
const { prefix, suffix } = maskApnsToken(d.apnsToken);
|
||||
return {
|
||||
id: d.id,
|
||||
platform: d.platform,
|
||||
hasApnsToken: d.apnsToken !== null && d.apnsToken !== "",
|
||||
apnsTokenPrefix: prefix,
|
||||
apnsTokenSuffix: suffix,
|
||||
apnsEnvironment: d.apnsEnvironment,
|
||||
lastSeenAt: d.lastSeen.toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
const diagnosticChannels: DiagnosticChannel[] = channels.map((c) => ({
|
||||
type: c.type,
|
||||
enabled: c.enabled,
|
||||
configPresent: isChannelConfigPresent(c.type, c.config),
|
||||
}));
|
||||
|
||||
// v1.4.49 — backed by the `push_attempts` table. Map `createdAt`
|
||||
// → `at` ISO string so the response shape matches the
|
||||
// `DiagnosticPushAttempt` interface above (the existing consumer
|
||||
// contract uses `at`, not `createdAt`).
|
||||
const recentPushAttempts: DiagnosticPushAttempt[] = pushAttempts.map((a) => ({
|
||||
eventType: a.eventType,
|
||||
channel: a.channel,
|
||||
result: a.result,
|
||||
reason: a.reason,
|
||||
at: a.createdAt.toISOString(),
|
||||
}));
|
||||
|
||||
const payload: DiagnosticPayload = {
|
||||
devices: diagnosticDevices,
|
||||
notificationChannels: diagnosticChannels,
|
||||
recentPushAttempts,
|
||||
};
|
||||
|
||||
return apiSuccess(payload);
|
||||
});
|
||||
@@ -305,10 +305,12 @@ describe("GET /api/analytics", () => {
|
||||
// contract the dashboard tile strip reads.
|
||||
it("returns the slim summaries slice when ?slice=summaries is set", async () => {
|
||||
const fiveDaysAgo = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000);
|
||||
// v1.4.36 — slim slice now opens with a cheap `SELECT COUNT(*) FROM
|
||||
// measurement_rollups` probe. Force `n: 0` so the route takes the
|
||||
// legacy heavy-aggregate fallback path that this test's fixtures
|
||||
// expect (aggregate + latest, two `$queryRaw` passes).
|
||||
// v1.4.36 — slim slice opens with a cheap `SELECT COUNT(*) FROM
|
||||
// measurement_rollups` probe; `n: 0` forces the cold-fallback
|
||||
// path. v1.4.48 M0 split that path's single aggregate query into
|
||||
// two parallel ones (all-time + windowed), so the cold fixture
|
||||
// now mocks 4 `$queryRaw` calls: coverage + allTime + windowed +
|
||||
// latest.
|
||||
vi.mocked(prisma.$queryRaw)
|
||||
.mockResolvedValueOnce([{ n: BigInt(0) }] as never)
|
||||
.mockResolvedValueOnce([
|
||||
@@ -318,6 +320,11 @@ describe("GET /api/analytics", () => {
|
||||
min_value: 80.0,
|
||||
max_value: 84.5,
|
||||
mean_value: 82.1,
|
||||
},
|
||||
] as never)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
avg7: 82.0,
|
||||
avg30: 82.2,
|
||||
slope7: -0.05,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*
|
||||
* The toggle is reachable from Settings → Insights as a `<Switch>`
|
||||
* labelled "Hide Coach" / "Coach ausblenden". Default `false` (Coach
|
||||
* visible) — see migration `0076_v1447_user_disable_coach` for the
|
||||
* visible) — see migration `0078_v1447_user_disable_coach` for the
|
||||
* column-level documentation.
|
||||
*
|
||||
* Idempotent. The endpoint always returns the resolved next-state
|
||||
@@ -19,13 +19,22 @@
|
||||
* batch settings round-trips after a passkey re-pair and a tight
|
||||
* 5/min would surface as a 429 on a normal onboarding flow.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
|
||||
const patchBodySchema = z.object({ disableCoach: z.boolean() });
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const PATCH_RATE_LIMIT = 60;
|
||||
@@ -72,19 +81,13 @@ export const PATCH = apiHandler(async (req: Request) => {
|
||||
throw new HttpError(422, "disable-coach.body.invalid_json");
|
||||
}
|
||||
|
||||
// Hand-rolled validation — single boolean field. Keeps the
|
||||
// prompt-injection surface visibly empty in code review (no string
|
||||
// ever reaches an LLM).
|
||||
if (
|
||||
!body ||
|
||||
typeof body !== "object" ||
|
||||
typeof (body as { disableCoach?: unknown }).disableCoach !== "boolean"
|
||||
) {
|
||||
const parsed = patchBodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
annotate({ action: { name: "auth.me.disable-coach.patch.invalid_shape" } });
|
||||
throw new HttpError(422, "disable-coach.body.invalid_shape");
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const next = (body as { disableCoach: boolean }).disableCoach;
|
||||
const next = parsed.data.disableCoach;
|
||||
|
||||
const previous = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
toJson: <T,>(v: T) => v,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkPatch(body: unknown): Request {
|
||||
return new Request("http://localhost/api/auth/me/notification-prefs", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me/notification-prefs", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/notification-prefs"),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the documented defaults for a fresh user (null row)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
notificationPrefs: null,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/notification-prefs"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: { medication: { clientManaged: boolean } };
|
||||
};
|
||||
expect(env.data).toEqual({ medication: { clientManaged: false } });
|
||||
});
|
||||
|
||||
it("returns the resolved prefs when the row holds a value", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
notificationPrefs: { medication: { clientManaged: true } },
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/notification-prefs"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: { medication: { clientManaged: boolean } };
|
||||
};
|
||||
expect(env.data).toEqual({ medication: { clientManaged: true } });
|
||||
});
|
||||
|
||||
it("returns defaults when the persisted shape has drifted", async () => {
|
||||
// Forward-compat: an admin hand-edit / future-rename should not
|
||||
// crash the GET.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
notificationPrefs: { unknownCategory: { foo: "bar" } },
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/notification-prefs"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: { medication: { clientManaged: boolean } };
|
||||
};
|
||||
expect(env.data).toEqual({ medication: { clientManaged: false } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ medication: { clientManaged: true } }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("flips medication.clientManaged on and writes the audit row", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
notificationPrefs: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ medication: { clientManaged: true } }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: { medication: { clientManaged: boolean } };
|
||||
};
|
||||
expect(env.data).toEqual({ medication: { clientManaged: true } });
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: {
|
||||
notificationPrefs: { medication: { clientManaged: true } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.notification-prefs.update",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
previous: { medication: { clientManaged: false } },
|
||||
next: { medication: { clientManaged: true } },
|
||||
changed: ["medication"],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects malformed JSON with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const req = new Request(
|
||||
"http://localhost/api/auth/me/notification-prefs",
|
||||
{
|
||||
method: "PATCH",
|
||||
body: "{ not valid json",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a non-boolean clientManaged with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ medication: { clientManaged: "yes" } }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deep-merges over existing siblings without overwriting them", async () => {
|
||||
// Forward-compat: a row that already carries an unknown sibling
|
||||
// category (e.g. a future "mood" key persisted by a newer client)
|
||||
// must survive a PATCH that only touches `medication`. The route
|
||||
// parses with the current zod schema first, then deep-merges the
|
||||
// input over the parsed (defaulted) base. Today, "unknown sibling"
|
||||
// resolves back to defaults; the test pins that the medication
|
||||
// PATCH still lands and that future schema growth (adding new
|
||||
// siblings to the zod shape) will preserve them.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
notificationPrefs: { medication: { clientManaged: false } },
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ medication: { clientManaged: true } }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: {
|
||||
notificationPrefs: { medication: { clientManaged: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the merged shape unchanged on an empty PATCH body", async () => {
|
||||
// Idempotent — an empty body keeps the row as-is and still writes
|
||||
// the audit trail (mirrors the disable-coach posture).
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
notificationPrefs: { medication: { clientManaged: true } },
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: { medication: { clientManaged: boolean } };
|
||||
};
|
||||
expect(env.data).toEqual({ medication: { clientManaged: true } });
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: {
|
||||
notificationPrefs: { medication: { clientManaged: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user rate-limit fires", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 30_000,
|
||||
});
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ medication: { clientManaged: true } }),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* v1.4.49 M-DOUBLE-REMINDER — per-user notification preferences endpoint.
|
||||
*
|
||||
* GET /api/auth/me/notification-prefs — current resolved prefs.
|
||||
* PATCH /api/auth/me/notification-prefs — body `{ medication?: { clientManaged?: boolean } }`.
|
||||
* Deep-merges the supplied
|
||||
* shape over the persisted
|
||||
* row so future categories
|
||||
* slot in next to
|
||||
* `medication` without
|
||||
* overwriting siblings.
|
||||
*
|
||||
* Auth is shared via `requireAuth()` so cookie sessions + Bearer
|
||||
* tokens both work (the iOS app uses Bearer). Default shape lives in
|
||||
* `parseNotificationPrefs` — a null DB row resolves to
|
||||
* `{ medication: { clientManaged: false } }`, preserving the legacy
|
||||
* server-side reminder behaviour for any user the iOS client has not
|
||||
* yet opted in.
|
||||
*
|
||||
* Idempotent. The endpoint always returns the fully-resolved next
|
||||
* state so the client can hard-set the optimistic update without an
|
||||
* extra round-trip. Rate-limit is intentionally generous (60/min) —
|
||||
* matches the disable-coach route which is the closest analogue.
|
||||
*/
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import {
|
||||
notificationPrefsSchema,
|
||||
parseNotificationPrefs,
|
||||
resolveNotificationPrefs,
|
||||
} from "@/lib/validations/notification-prefs";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const PATCH_RATE_LIMIT = 60;
|
||||
const PATCH_WINDOW_MS = 60_000;
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "auth.me.notification-prefs.get" } });
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { notificationPrefs: true },
|
||||
});
|
||||
return apiSuccess(parseNotificationPrefs(row?.notificationPrefs ?? null));
|
||||
});
|
||||
|
||||
export const PATCH = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`notification-prefs:patch:${user.id}`,
|
||||
PATCH_RATE_LIMIT,
|
||||
PATCH_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new HttpError(422, "notification-prefs.body.invalid_json");
|
||||
}
|
||||
|
||||
const parsed = notificationPrefsSchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "auth.me.notification-prefs.patch.invalid_shape" },
|
||||
meta: { issues: parsed.error.issues.length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
// Deep-merge the partial input over the current row so a PATCH that
|
||||
// only touches `medication` does not overwrite future sibling
|
||||
// categories (mood, personal_records, ...). The full resolved shape
|
||||
// is persisted so the column layout stays stable across schema
|
||||
// additions.
|
||||
const current = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { notificationPrefs: true },
|
||||
});
|
||||
const previous = parseNotificationPrefs(current?.notificationPrefs ?? null);
|
||||
const merged = resolveNotificationPrefs(
|
||||
current?.notificationPrefs ?? null,
|
||||
parsed.data,
|
||||
);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { notificationPrefs: toJson(merged) },
|
||||
});
|
||||
|
||||
// Capture which categories the PATCH actually touched so the audit
|
||||
// trail records the user's intent, not just the full resolved row.
|
||||
const changedCategories = Object.keys(parsed.data);
|
||||
|
||||
await auditLog("user.notification-prefs.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: {
|
||||
previous,
|
||||
next: merged,
|
||||
changed: changedCategories,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "user.notification_prefs.update" },
|
||||
meta: {
|
||||
changed: changedCategories,
|
||||
medication_client_managed: merged.medication.clientManaged,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(merged);
|
||||
});
|
||||
@@ -34,7 +34,7 @@ export const GET = apiHandler(async () => {
|
||||
lastReportPracticeName: user.lastReportPracticeName ?? null,
|
||||
// v1.4.47 W3 — per-user Coach opt-out. Default `false` if the
|
||||
// column is absent (partial-deploy rollback safety, see migration
|
||||
// 0076 commentary). Every Coach mount point on the client checks
|
||||
// 0078 commentary). Every Coach mount point on the client checks
|
||||
// `user.disableCoach` BELOW the operator-level `flags.coach`
|
||||
// short-circuit; both gates must agree to paint the affordance.
|
||||
disableCoach: user.disableCoach ?? false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -94,6 +94,14 @@ beforeEach(() => {
|
||||
} as never);
|
||||
});
|
||||
|
||||
// v1.4.48 L13 — restore real timers no matter how a test exits. A failed
|
||||
// assertion inside an `it` that opted into fake timers would otherwise
|
||||
// leak the fake clock into the next test in the file (or, worse, the
|
||||
// next file in the same vitest worker).
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("GET /api/dashboard/summary", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
@@ -653,7 +661,7 @@ describe("GET /api/dashboard/summary", () => {
|
||||
data: { compliance: { scheduledToday: number; takenToday: number } };
|
||||
};
|
||||
expect(body.data.compliance.scheduledToday).toBe(1);
|
||||
vi.useRealTimers();
|
||||
// v1.4.48 L13 — suite-level `afterEach` restores real timers.
|
||||
});
|
||||
|
||||
it("computes intake compliance for today", async () => {
|
||||
|
||||
@@ -36,6 +36,15 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
return {
|
||||
...actual,
|
||||
annotate: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
@@ -48,6 +57,7 @@ vi.mock("next/headers", () => ({
|
||||
import { PUT, __resetAuditDedupMemoForTests } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
|
||||
const SESSION_OK = {
|
||||
@@ -135,13 +145,14 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
|
||||
expect(call.data.userId).toBe("user-1");
|
||||
expect(call.data.action).toBe("dashboard.widgets.validation-failed");
|
||||
const details = JSON.parse(call.data.details) as {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
issues: Array<{ path: string; code: string }>;
|
||||
};
|
||||
expect(details.issues.length).toBe(2);
|
||||
for (const issue of details.issues) {
|
||||
// Audit row carries the same sanitised shape — issue.params never
|
||||
// hits the ledger either.
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
// v1.4.49 — audit row is the persisted surface. `message`
|
||||
// strips here so a future Zod code that embeds the offending
|
||||
// value cannot leak into the ledger.
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -165,6 +176,58 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
|
||||
expect(body2.details.issues.length).toBe(2);
|
||||
});
|
||||
|
||||
it("surfaces received_keys + received_shape_excerpt + zod_issues in the wide-event meta (v1.4.48 H-iOS-1)", async () => {
|
||||
const payload = { version: 2, widgets: [], extraGarbage: "from-ios" };
|
||||
const res = await callPut(makeReq(payload));
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
const annotated = vi.mocked(annotate).mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"dashboard.widgets.validation-failed",
|
||||
);
|
||||
expect(annotated, "validation-failed annotate call").toBeTruthy();
|
||||
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
|
||||
|
||||
// Top-level keys mirror the iOS-sent payload (NOT the schema keys).
|
||||
expect(meta.received_keys).toEqual(
|
||||
expect.arrayContaining(["version", "widgets", "extraGarbage"]),
|
||||
);
|
||||
|
||||
// Excerpt is a JSON-stringified prefix, hard-capped at 256 chars.
|
||||
expect(typeof meta.received_shape_excerpt).toBe("string");
|
||||
expect((meta.received_shape_excerpt as string).length).toBeLessThanOrEqual(
|
||||
256,
|
||||
);
|
||||
expect(meta.received_shape_excerpt as string).toContain("\"version\":2");
|
||||
|
||||
// zod_issues is the same sanitised array surfaced under details.issues.
|
||||
const issues = meta.zod_issues as Array<{ path: string; code: string }>;
|
||||
expect(Array.isArray(issues)).toBe(true);
|
||||
expect(issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("caps received_shape_excerpt at 256 chars even for a large iOS payload", async () => {
|
||||
const widgets = Array.from({ length: 30 }, (_, i) => ({
|
||||
id: `widget-${i}-${"x".repeat(20)}`,
|
||||
visible: true,
|
||||
order: i,
|
||||
}));
|
||||
const res = await callPut(makeReq({ version: 99, widgets }));
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
const annotated = vi.mocked(annotate).mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"dashboard.widgets.validation-failed",
|
||||
);
|
||||
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
|
||||
expect((meta.received_shape_excerpt as string).length).toBe(256);
|
||||
});
|
||||
|
||||
it("does not block the 422 response when the audit-row write rejects", async () => {
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
@@ -178,4 +241,65 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
|
||||
};
|
||||
expect(body.details.issues.length).toBe(2);
|
||||
});
|
||||
|
||||
it("redacts sensitive keys before writing the wide-event received_shape_excerpt (v1.4.49)", async () => {
|
||||
// Caller adds a credential-shaped field — must not land in the
|
||||
// wide-event excerpt verbatim. The denylist also covers nested
|
||||
// members so `payload.apiKey` is redacted, while `version` /
|
||||
// `widgets` stay readable for operator debug.
|
||||
const payload = {
|
||||
version: 2,
|
||||
widgets: [],
|
||||
apnsToken: "ff".repeat(32),
|
||||
authorization: "Bearer leaked",
|
||||
payload: { apiKey: "sk_live_xxx" },
|
||||
};
|
||||
const res = await callPut(makeReq(payload));
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
const annotated = vi.mocked(annotate).mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"dashboard.widgets.validation-failed",
|
||||
);
|
||||
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
|
||||
const excerpt = meta.received_shape_excerpt as string;
|
||||
|
||||
// Sensitive values are never written into the excerpt.
|
||||
expect(excerpt).not.toContain("ff".repeat(32));
|
||||
expect(excerpt).not.toContain("Bearer leaked");
|
||||
expect(excerpt).not.toContain("sk_live_xxx");
|
||||
// The redactor leaves the literal sentinel behind so an operator
|
||||
// can still see the shape of the rejected payload.
|
||||
expect(excerpt).toContain("[redacted]");
|
||||
// Non-sensitive keys still surface for debugging.
|
||||
expect(meta.received_keys).toEqual(
|
||||
expect.arrayContaining([
|
||||
"version",
|
||||
"widgets",
|
||||
"apnsToken",
|
||||
"authorization",
|
||||
"payload",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("strips `message` from the audit-ledger issues row (v1.4.49)", async () => {
|
||||
// The wide-event meta keeps full message for operator debugging,
|
||||
// but the persisted auditLog row must only carry `path` + `code`
|
||||
// so a future Zod code that embeds the offending value cannot
|
||||
// leak into the ledger.
|
||||
await callPut(makeReq({ version: 2, widgets: [] }));
|
||||
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { details: string };
|
||||
};
|
||||
const parsed = JSON.parse(call.data.details) as {
|
||||
issues: Array<Record<string, unknown>>;
|
||||
};
|
||||
for (const issue of parsed.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "path"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
buildPayloadDiagnostic,
|
||||
safeJson,
|
||||
returnAllZodIssues,
|
||||
sanitiseZodIssues,
|
||||
@@ -28,6 +29,7 @@ import { Prisma } from "@/generated/prisma/client";
|
||||
import { z } from "zod/v4";
|
||||
import { invalidateUserDashboardWidgets } from "@/lib/cache/invalidate";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
// Single source of truth — every widget id rendered by the Settings →
|
||||
@@ -145,9 +147,25 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
// `/api/admin/audit` for these without combing the iOS dev
|
||||
// console.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
// v1.4.48 H-iOS-1 — surface the iOS-sent payload shape alongside
|
||||
// the Zod rejection so a single wide-event line carries enough
|
||||
// detail to diagnose serialiser drift in `HealthLog-iOS`. We log
|
||||
// ONLY the top-level keys plus a hard 256-char JSON excerpt — never
|
||||
// the full body — so PII / token-like fields cannot leak. The
|
||||
// diagnostic shape is built by the shared `buildPayloadDiagnostic`
|
||||
// helper (v1.4.49) so the widget + series routes can't drift; the
|
||||
// body is routed through `redactSensitiveFields` first so any
|
||||
// future field matching the denylist (password / token / secret /
|
||||
// apiKey / authorization / csrfState / nonce) lands as the literal
|
||||
// `"[redacted]"` instead of its raw value.
|
||||
const payloadDiagnostic = buildPayloadDiagnostic(redactSensitiveFields(body));
|
||||
annotate({
|
||||
action: { name: "dashboard.widgets.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
meta: {
|
||||
issue_count: issues.length,
|
||||
...payloadDiagnostic,
|
||||
zod_issues: issues,
|
||||
},
|
||||
});
|
||||
// v1.4.43 B2 — gate the breadcrumb behind a 60 s (userId, action) dedup
|
||||
// so a misbehaving iOS client retrying every second cannot pump
|
||||
@@ -160,12 +178,20 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
)
|
||||
) {
|
||||
// Best-effort breadcrumb — never block the 422 on a write miss.
|
||||
// v1.4.49 — strip `message` from the audit-ledger row so Zod
|
||||
// codes that embed the offending value in their default message
|
||||
// (`invalid_enum_value` and similar) cannot leak user content
|
||||
// through the audit surface. The wide-event excerpt above
|
||||
// already carries the shape signal for operator debugging.
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "dashboard.widgets.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -85,12 +85,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
action: { name: "devices.register.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
// v1.4.49 — strip `message` from the audit-ledger row. Device
|
||||
// registration carries `token` + `apnsToken`; the default Zod
|
||||
// message for `invalid_format` echoes the offending value, which
|
||||
// would persist an APNs token (or a forged near-miss) into the
|
||||
// audit ledger.
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "devices.register.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -103,12 +103,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
action: { name: "ingest.medication.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
// v1.4.49 — strip `message` from the audit-ledger row; external
|
||||
// ingest carries free-text `medicationName` + opaque
|
||||
// `idempotencyKey`.
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: apiToken.userId,
|
||||
action: "ingest.medication.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -152,12 +152,18 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
action: { name: "integrations.healthkit.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
// v1.4.49 — strip `message` from the audit-ledger row; the
|
||||
// HealthKit patch payload carries caller-provided `id` + `kind`
|
||||
// strings that Zod can echo.
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "integrations.healthkit.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -70,12 +70,20 @@ export const PUT = apiHandler(
|
||||
action: { name: "measurements.update.validation-failed" },
|
||||
meta: { issue_count: issues.length, measurement_id: id },
|
||||
});
|
||||
// v1.4.49 — strip `message` from the audit-ledger row; the
|
||||
// update schema carries free-text `notes`.
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "measurements.update.validation-failed",
|
||||
details: JSON.stringify({ issues, measurementId: id }),
|
||||
details: JSON.stringify({
|
||||
issues: auditIssues,
|
||||
measurementId: id,
|
||||
}),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -56,12 +56,18 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
action: { name: "measurements.list.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
// v1.4.49 — strip `message` from the audit-ledger row to keep
|
||||
// any free-text query slice (custom `dayKey`, attacker-shaped
|
||||
// `type=`) out of the persisted breadcrumb.
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "measurements.list.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -583,12 +589,17 @@ async function postMeasurement(request: NextRequest) {
|
||||
action: { name: "measurements.create.batch.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
// v1.4.49 — strip `message` from the audit-ledger row; batch
|
||||
// rows carry free-text `notes` per measurement.
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "measurements.create.batch.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -671,12 +682,17 @@ async function postMeasurement(request: NextRequest) {
|
||||
action: { name: "measurements.create.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
// v1.4.49 — strip `message` from the audit-ledger row; single
|
||||
// measurement carries free-text `notes`.
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "measurements.create.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -16,6 +16,15 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
return {
|
||||
...actual,
|
||||
annotate: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
@@ -28,6 +37,7 @@ vi.mock("next/headers", () => ({
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -157,6 +167,50 @@ describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () =>
|
||||
expect(call.data.action).toBe("measurements.series.validation-failed");
|
||||
});
|
||||
|
||||
it("surfaces received_keys + received_shape_excerpt + zod_issues in the wide-event meta (v1.4.48 H-iOS-2)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await GET(req("kind=garbage&days=0&extraGarbage=fromIos"));
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
const annotated = vi.mocked(annotate).mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"measurements.series.validation-failed",
|
||||
);
|
||||
expect(annotated, "validation-failed annotate call").toBeTruthy();
|
||||
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
|
||||
|
||||
expect(meta.received_keys).toEqual(
|
||||
expect.arrayContaining(["kind", "days", "extraGarbage"]),
|
||||
);
|
||||
expect(typeof meta.received_shape_excerpt).toBe("string");
|
||||
expect((meta.received_shape_excerpt as string).length).toBeLessThanOrEqual(
|
||||
256,
|
||||
);
|
||||
expect(meta.received_shape_excerpt as string).toContain("\"kind\":\"garbage\"");
|
||||
|
||||
const issues = meta.zod_issues as Array<{ path: string; code: string }>;
|
||||
expect(Array.isArray(issues)).toBe(true);
|
||||
expect(issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("caps received_shape_excerpt at 256 chars even for a long iOS query", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const long = "x".repeat(500);
|
||||
const res = await GET(req(`kind=garbage&days=0&junk=${long}`));
|
||||
expect(res.status).toBe(422);
|
||||
const annotated = vi.mocked(annotate).mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"measurements.series.validation-failed",
|
||||
);
|
||||
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
|
||||
expect((meta.received_shape_excerpt as string).length).toBe(256);
|
||||
});
|
||||
|
||||
it("does not block the 422 when the audit-row write rejects", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
@@ -165,4 +219,47 @@ describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () =>
|
||||
const res = await GET(req("kind=garbage&days=0"));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("redacts sensitive query keys before writing the wide-event received_shape_excerpt (v1.4.49)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// An attacker-shaped query that mixes a valid `kind` with a
|
||||
// credential-shaped `token` param. The excerpt must keep `kind`
|
||||
// readable for operator debug but redact the token verbatim.
|
||||
const res = await GET(
|
||||
req("kind=garbage&days=0&token=hlk_secret_value&apiKey=sk_live_xxx"),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
const annotated = vi.mocked(annotate).mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"measurements.series.validation-failed",
|
||||
);
|
||||
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
|
||||
const excerpt = meta.received_shape_excerpt as string;
|
||||
|
||||
expect(excerpt).not.toContain("hlk_secret_value");
|
||||
expect(excerpt).not.toContain("sk_live_xxx");
|
||||
expect(excerpt).toContain("[redacted]");
|
||||
// The key inventory still surfaces so operators see the shape.
|
||||
expect(meta.received_keys).toEqual(
|
||||
expect.arrayContaining(["kind", "days", "token", "apiKey"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("strips `message` from the audit-ledger issues row (v1.4.49)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
await GET(req("kind=garbage&days=0"));
|
||||
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { details: string };
|
||||
};
|
||||
const parsed = JSON.parse(call.data.details) as {
|
||||
issues: Array<Record<string, unknown>>;
|
||||
};
|
||||
for (const issue of parsed.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "path"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -14,11 +14,13 @@ import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
buildPayloadDiagnostic,
|
||||
returnAllZodIssues,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
const kindEnum = z.enum([
|
||||
@@ -70,23 +72,42 @@ function stdDev(values: number[]): number {
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const parsed = querySchema.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams),
|
||||
);
|
||||
const rawQuery = Object.fromEntries(request.nextUrl.searchParams);
|
||||
const parsed = querySchema.safeParse(rawQuery);
|
||||
if (!parsed.success) {
|
||||
// v1.4.43 W6 — iOS chart loader hot path; multi-issue 422 +
|
||||
// audit breadcrumb keyed `measurements.series.validation-failed`.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
// v1.4.48 H-iOS-2 — surface the iOS-sent query shape alongside
|
||||
// the Zod rejection. Top-level keys + a hard 256-char JSON excerpt
|
||||
// only; never the full payload, so token-shaped query params (we
|
||||
// do not currently accept any but the truncation keeps that
|
||||
// invariant cheap) cannot leak. Shared helper via v1.4.49 so the
|
||||
// widget + series routes can't drift on the diagnostic shape; the
|
||||
// query map is routed through `redactSensitiveFields` first so any
|
||||
// future credential-shaped query string (`token`, `apiKey`,
|
||||
// `csrfState`) redacts to `"[redacted]"` before the truncate.
|
||||
const payloadDiagnostic = buildPayloadDiagnostic(redactSensitiveFields(rawQuery));
|
||||
annotate({
|
||||
action: { name: "measurements.series.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
meta: {
|
||||
issue_count: issues.length,
|
||||
...payloadDiagnostic,
|
||||
zod_issues: issues,
|
||||
},
|
||||
});
|
||||
// v1.4.49 — strip `message` from the audit-ledger row so Zod
|
||||
// codes that embed the offending value (`invalid_enum_value` etc.)
|
||||
// cannot leak user content through the audit surface.
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "measurements.series.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -62,12 +62,20 @@ export const POST = apiHandler(
|
||||
action: { name: "medications.intake.import.validation-failed" },
|
||||
meta: { issue_count: issues.length, medication_id: id },
|
||||
});
|
||||
// v1.4.49 — strip `message` from the audit-ledger row; CSV
|
||||
// imports carry caller-provided strings that Zod may echo.
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "medications.intake.import.validation-failed",
|
||||
details: JSON.stringify({ issues, medicationId: id }),
|
||||
details: JSON.stringify({
|
||||
issues: auditIssues,
|
||||
medicationId: id,
|
||||
}),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -49,12 +49,20 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
action: { name: "medications.intake.create.validation-failed" },
|
||||
meta: { issue_count: issues.length, medication_id: id },
|
||||
});
|
||||
// v1.4.49 — strip `message` from the audit-ledger row; the
|
||||
// intake payload carries `idempotencyKey` (opaque caller string).
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "medications.intake.create.validation-failed",
|
||||
details: JSON.stringify({ issues, medicationId: id }),
|
||||
details: JSON.stringify({
|
||||
issues: auditIssues,
|
||||
medicationId: id,
|
||||
}),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -211,12 +219,19 @@ export const GET = apiHandler(
|
||||
action: { name: "medications.intake.list.validation-failed" },
|
||||
meta: { issue_count: issues.length, medication_id: id },
|
||||
});
|
||||
// v1.4.49 — strip `message` from the audit-ledger row.
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "medications.intake.list.validation-failed",
|
||||
details: JSON.stringify({ issues, medicationId: id }),
|
||||
details: JSON.stringify({
|
||||
issues: auditIssues,
|
||||
medicationId: id,
|
||||
}),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -103,6 +103,14 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
// v1.4.48 L13 — restore real timers no matter how a test exits. A failed
|
||||
// assertion inside an `it` that opted into fake timers would otherwise
|
||||
// leak the fake clock into the next test in the file (or, worse, the
|
||||
// next file in the same vitest worker).
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("GET /api/medications/intake", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
@@ -262,7 +270,7 @@ describe("GET /api/medications/intake", () => {
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationIntakeEvent.createMany).not.toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
// v1.4.48 L13 — suite-level `afterEach` restores real timers.
|
||||
});
|
||||
|
||||
it("returns compliance buckets for the last N days", async () => {
|
||||
|
||||
@@ -120,12 +120,16 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
action: { name: "medications.intake.bulk.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
// v1.4.49 — strip `message` from the audit-ledger row.
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "medications.intake.bulk.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -82,12 +82,17 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
action: { name: "medications.intake.list.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
// v1.4.49 — strip `message` from the audit-ledger row; iOS-sent
|
||||
// query strings can flow into Zod issue messages.
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "medications.intake.list.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -288,12 +293,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
action: { name: "medications.intake.update.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
// v1.4.49 — strip `message` from the audit-ledger row.
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "medications.intake.update.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -75,12 +75,17 @@ export const PUT = apiHandler(
|
||||
action: { name: "mood-entries.update.validation-failed" },
|
||||
meta: { issue_count: issues.length, moodEntryId: id },
|
||||
});
|
||||
// v1.4.49 — strip `message` from the audit-ledger row; mood
|
||||
// update carries free-text `note` + `tags`.
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "mood-entries.update.validation-failed",
|
||||
details: JSON.stringify({ issues, moodEntryId: id }),
|
||||
details: JSON.stringify({ issues: auditIssues, moodEntryId: id }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -122,12 +122,17 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
action: { name: "mood.bulk.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
// v1.4.49 — strip `message` from the audit-ledger row; bulk mood
|
||||
// entries carry free-text `note` + `tags` per row.
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "mood.bulk.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -44,12 +44,20 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
action: { name: "mood-entries.list.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
// v1.4.49 — strip `message` from the audit-ledger row so Zod
|
||||
// codes that embed the offending value (`invalid_enum_value` etc.)
|
||||
// cannot leak user-typed content. Mood-entries carry free-text
|
||||
// `note` + `tags`; defensive strip everywhere mood content can
|
||||
// reach a Zod issue.
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "mood-entries.list.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -116,12 +124,18 @@ async function postMoodEntry(request: NextRequest) {
|
||||
action: { name: "mood-entries.create.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
// v1.4.49 — strip `message` from the audit-ledger row; the
|
||||
// mood-create schema's free-text `note` + `tags` could land in a
|
||||
// Zod issue message verbatim.
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "mood-entries.create.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -57,10 +57,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
: defaultLocale;
|
||||
const t = getServerTranslator(locale).t;
|
||||
|
||||
// Send as MEDICATION_REMINDER so the dispatcher's time-sensitive /
|
||||
// priority-10 branch fires. Otherwise the test runs at the default
|
||||
// `active` interruption-level and iOS may summarise it into the
|
||||
// Notification Center rather than presenting a lock-screen banner —
|
||||
// leaving the user with no signal whether real medication reminders
|
||||
// (which DO use time-sensitive) will surface on the lock screen.
|
||||
// The title/body strings still read "Test notification" so the user
|
||||
// doesn't mistake this for an actual scheduled dose.
|
||||
const result = await sendViaApns(user.id, {
|
||||
title: t("notifications.admin.testNotificationTitle"),
|
||||
message: t("notifications.admin.testNotificationBody"),
|
||||
eventType: "SYSTEM_ALERT",
|
||||
eventType: "MEDICATION_REMINDER",
|
||||
});
|
||||
|
||||
annotate({
|
||||
|
||||
@@ -65,12 +65,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
action: { name: "tokens.create.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
// v1.4.49 — strip `message` from the audit-ledger row; the
|
||||
// mint payload carries a free-text `name`.
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "tokens.create.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* v1.4.48 M3 — unit-level coverage for the atomic nonce-consumption
|
||||
* branch.
|
||||
*
|
||||
* The integration tests in `tests/integration/withings-oauth*.test.ts`
|
||||
* already pin the full handshake against real Postgres (happy path,
|
||||
* replay, expired, cross-user). This file specifically targets the
|
||||
* delete-first race window the M3 refactor closed: two concurrent
|
||||
* callbacks with the same nonce — the first wins, the second hits
|
||||
* Prisma P2025 and lands on the replay branch.
|
||||
*
|
||||
* Co-tests M7 — non-P2025 delete failures (DB-connection drop /
|
||||
* integrity violation) surface via Wide-Event `addWarning` instead
|
||||
* of being silently swallowed.
|
||||
*
|
||||
* v1.4.49 — assertions updated for the four-way reason-tag split
|
||||
* (`csrf1`, `replay`, `expired`, `cross_user`) and the QA-2
|
||||
* err.name-only warning template.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({
|
||||
user: { id: "user-1" },
|
||||
session: { id: "sess-1" },
|
||||
})),
|
||||
HttpError: class HttpError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
withingsOAuthState: {
|
||||
delete: vi.fn(),
|
||||
},
|
||||
withingsConnection: {
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: vi.fn((s: string) => `enc(${s})`),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/withings/client", () => ({
|
||||
exchangeCode: vi.fn(),
|
||||
WITHINGS_OAUTH_SCOPE: "user.metrics,user.activity",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/withings/credentials", () => ({
|
||||
getUserWithingsCredentials: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/withings/sync", () => ({
|
||||
setupWebhook: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/integrations/status", () => ({
|
||||
markReconnected: vi.fn(),
|
||||
}));
|
||||
|
||||
const addWarning = vi.fn();
|
||||
const annotateMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: annotateMock,
|
||||
getEvent: vi.fn(() => ({ addWarning, setError: vi.fn() })),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
const NONCE = "abcdefghijklmnopqrstuv"; // 22-char base64url
|
||||
const APP_URL = "http://localhost:3000";
|
||||
|
||||
function callbackRequest(state: string, cookieState: string): NextRequest {
|
||||
return new NextRequest(
|
||||
`http://localhost/api/withings/callback?code=auth-code-1&state=${encodeURIComponent(state)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: `withings_state=${cookieState}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.NEXT_PUBLIC_APP_URL = APP_URL;
|
||||
});
|
||||
|
||||
describe("withings/callback atomic nonce consumption (M3)", () => {
|
||||
it("concurrent callbacks with the same nonce — only one succeeds, the second hits the P2025 replay branch", async () => {
|
||||
// First callback: delete returns the row (win the race).
|
||||
// Second callback: delete throws P2025 (row already consumed).
|
||||
const winningRow = {
|
||||
nonce: NONCE,
|
||||
userId: "user-1",
|
||||
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
const p2025 = new Prisma.PrismaClientKnownRequestError(
|
||||
"An operation failed because it depends on one or more records that were required but not found.",
|
||||
{ code: "P2025", clientVersion: "x" },
|
||||
);
|
||||
|
||||
const deleteMock = vi.mocked(prisma.withingsOAuthState.delete);
|
||||
deleteMock
|
||||
.mockResolvedValueOnce(winningRow as never)
|
||||
.mockRejectedValueOnce(p2025);
|
||||
|
||||
// Stub the rest of the happy path for the winning request — code
|
||||
// exchange returns plausible tokens so the upsert + redirect run.
|
||||
const { getUserWithingsCredentials } = await import(
|
||||
"@/lib/withings/credentials"
|
||||
);
|
||||
vi.mocked(getUserWithingsCredentials).mockResolvedValue({
|
||||
clientId: "cid",
|
||||
clientSecret: "csec",
|
||||
} as never);
|
||||
const { exchangeCode } = await import("@/lib/withings/client");
|
||||
vi.mocked(exchangeCode).mockResolvedValue({
|
||||
userid: "wuid",
|
||||
access_token: "atok",
|
||||
refresh_token: "rtok",
|
||||
expires_in: 3600,
|
||||
} as never);
|
||||
|
||||
// Fire both legs concurrently. The mock's call ordering is FIFO,
|
||||
// so whichever `await` hits the Prisma mock first gets the
|
||||
// success result. Both legs use the same nonce + cookie value.
|
||||
const reqA = callbackRequest(NONCE, NONCE);
|
||||
const reqB = callbackRequest(NONCE, NONCE);
|
||||
const [resA, resB] = await Promise.all([GET(reqA), GET(reqB)]);
|
||||
|
||||
// Both responses are 307 redirects.
|
||||
expect(resA.status).toBe(307);
|
||||
expect(resB.status).toBe(307);
|
||||
|
||||
// Exactly one delete-attempt per leg — the atomic shape collapses
|
||||
// findUnique + delete into a single round-trip.
|
||||
expect(deleteMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Exactly one leg landed on `withings=connected`, exactly one
|
||||
// landed on the replay branch (`withings=error&reason=replay`).
|
||||
const locations = [resA, resB]
|
||||
.map((r) => r.headers.get("location"))
|
||||
.sort();
|
||||
expect(locations).toHaveLength(2);
|
||||
expect(
|
||||
locations.some((l) => l?.includes("withings=connected")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
locations.some((l) => l?.includes("withings=error&reason=replay")),
|
||||
).toBe(true);
|
||||
|
||||
// P2025 must NOT surface as a warning — it's the expected
|
||||
// replay-branch signal, not a real infra problem.
|
||||
expect(addWarning).not.toHaveBeenCalled();
|
||||
|
||||
// Wide-Event annotation carries the same reason so operators can
|
||||
// grep without DB-shell.
|
||||
expect(annotateMock).toHaveBeenCalledWith({ meta: { reason: "replay" } });
|
||||
});
|
||||
|
||||
it("non-P2025 delete failure (e.g. connection drop) surfaces a Wide-Event warning carrying err.name only, and bounces to the error page", async () => {
|
||||
// M7 — a real infra failure on the consume-delete must not be
|
||||
// silently swallowed.
|
||||
//
|
||||
// v1.4.49 QA-2 — the warning template now interpolates `err.name`
|
||||
// rather than `${err}`. A Prisma error whose `message` echoes the
|
||||
// offending value (e.g. the raw nonce) into the wide-event log
|
||||
// would otherwise leak it.
|
||||
class TimeoutError extends Error {
|
||||
constructor() {
|
||||
super("connection terminated unexpectedly nonce=abc123");
|
||||
this.name = "TimeoutError";
|
||||
}
|
||||
}
|
||||
vi.mocked(prisma.withingsOAuthState.delete).mockRejectedValueOnce(
|
||||
new TimeoutError(),
|
||||
);
|
||||
|
||||
const req = callbackRequest(NONCE, NONCE);
|
||||
const res = await GET(req);
|
||||
|
||||
expect(res.status).toBe(307);
|
||||
expect(res.headers.get("location")).toContain(
|
||||
"withings=error&reason=state",
|
||||
);
|
||||
|
||||
// The warning carries the error NAME so the audit trail can
|
||||
// distinguish "row was gone" (P2025, no warning) from "DB unhappy"
|
||||
// (warning, infra signal) without ever interpolating the message
|
||||
// body.
|
||||
expect(addWarning).toHaveBeenCalledTimes(1);
|
||||
expect(addWarning).toHaveBeenCalledWith(
|
||||
"oauth-state-delete failed: TimeoutError",
|
||||
);
|
||||
// Defensive: the message body (which Prisma may use to echo the
|
||||
// offending value) must not appear in the warning.
|
||||
expect(addWarning).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("nonce=abc123"),
|
||||
);
|
||||
expect(addWarning).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("connection terminated"),
|
||||
);
|
||||
});
|
||||
|
||||
it("delete succeeds but row has expired — validity check trips with reason=expired, no token exchange", async () => {
|
||||
const expiredRow = {
|
||||
nonce: NONCE,
|
||||
userId: "user-1",
|
||||
expiresAt: new Date(Date.now() - 60_000),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
vi.mocked(prisma.withingsOAuthState.delete).mockResolvedValueOnce(
|
||||
expiredRow as never,
|
||||
);
|
||||
|
||||
const { exchangeCode } = await import("@/lib/withings/client");
|
||||
|
||||
const req = callbackRequest(NONCE, NONCE);
|
||||
const res = await GET(req);
|
||||
|
||||
expect(res.status).toBe(307);
|
||||
expect(res.headers.get("location")).toContain(
|
||||
"withings=error&reason=expired",
|
||||
);
|
||||
// Single atomic round-trip consumed the row; no token exchange.
|
||||
expect(exchangeCode).not.toHaveBeenCalled();
|
||||
expect(annotateMock).toHaveBeenCalledWith({ meta: { reason: "expired" } });
|
||||
});
|
||||
|
||||
it("delete succeeds but row's userId mismatches session — validity check trips with reason=cross_user, no token exchange", async () => {
|
||||
const crossUserRow = {
|
||||
nonce: NONCE,
|
||||
userId: "user-OTHER",
|
||||
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
vi.mocked(prisma.withingsOAuthState.delete).mockResolvedValueOnce(
|
||||
crossUserRow as never,
|
||||
);
|
||||
|
||||
const { exchangeCode } = await import("@/lib/withings/client");
|
||||
|
||||
const req = callbackRequest(NONCE, NONCE);
|
||||
const res = await GET(req);
|
||||
|
||||
expect(res.status).toBe(307);
|
||||
expect(res.headers.get("location")).toContain(
|
||||
"withings=error&reason=cross_user",
|
||||
);
|
||||
expect(exchangeCode).not.toHaveBeenCalled();
|
||||
expect(annotateMock).toHaveBeenCalledWith({ meta: { reason: "cross_user" } });
|
||||
});
|
||||
|
||||
it("non-P2025 infra error branch annotates reason=state on the Wide Event (legacy fallback for unclassified DB failures)", async () => {
|
||||
// v1.4.49 — the four named reasons (csrf1, replay, expired,
|
||||
// cross_user) cover every "we know what went wrong" branch. A
|
||||
// raw infra failure is intentionally still tagged `state` —
|
||||
// operators reading the wide event will see the err.name in the
|
||||
// warning and the `reason=state` annotation, which is enough to
|
||||
// distinguish it from the four classified branches.
|
||||
class TimeoutError extends Error {
|
||||
constructor() {
|
||||
super("connection terminated");
|
||||
this.name = "TimeoutError";
|
||||
}
|
||||
}
|
||||
vi.mocked(prisma.withingsOAuthState.delete).mockRejectedValueOnce(
|
||||
new TimeoutError(),
|
||||
);
|
||||
|
||||
const req = callbackRequest(NONCE, NONCE);
|
||||
await GET(req);
|
||||
|
||||
expect(annotateMock).toHaveBeenCalledWith({ meta: { reason: "state" } });
|
||||
});
|
||||
|
||||
it("URL state vs cookie state mismatch short-circuits with reason=csrf1 BEFORE the atomic delete fires", async () => {
|
||||
// CSRF leg 1 — must reject without touching the ledger so a
|
||||
// probe can't grief legitimate rows by issuing deletes for
|
||||
// nonces it doesn't legitimately hold the cookie for.
|
||||
const req = callbackRequest("aaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbb");
|
||||
const res = await GET(req);
|
||||
|
||||
expect(res.status).toBe(307);
|
||||
expect(res.headers.get("location")).toContain(
|
||||
"withings=error&reason=csrf1",
|
||||
);
|
||||
expect(prisma.withingsOAuthState.delete).not.toHaveBeenCalled();
|
||||
expect(annotateMock).toHaveBeenCalledWith({ meta: { reason: "csrf1" } });
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { getUserWithingsCredentials } from "@/lib/withings/credentials";
|
||||
import { WITHINGS_OAUTH_STATE_COOKIE } from "@/lib/withings/oauth-state";
|
||||
import { setupWebhook } from "@/lib/withings/sync";
|
||||
import { markReconnected } from "@/lib/integrations/status";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
|
||||
@@ -23,6 +24,17 @@ import { timingSafeEqual } from "node:crypto";
|
||||
* runs. The row is consumed (deleted) on every exit branch so a
|
||||
* replay of the same nonce fails the second time.
|
||||
*
|
||||
* v1.4.48 M3 — the row is now consumed via a single delete-first
|
||||
* round-trip. Prisma `delete` returns the row on success, so the
|
||||
* post-delete validity checks (`expiresAt`, `userId`) still run
|
||||
* against the consumed payload. Two concurrent callbacks with the
|
||||
* same nonce now race at the Postgres row level — only one delete
|
||||
* wins, the other hits P2025 ("record not found") and is treated as
|
||||
* a replay, bounced to the error page. Previously the `findUnique +
|
||||
* separate delete` shape left a tiny RTT window where both legs
|
||||
* could pass the lookup before either issued the delete, contrary
|
||||
* to the docstring's atomicity promise.
|
||||
*
|
||||
* Closes the v1.4.43 security audit L-1 finding: the cookie now
|
||||
* carries only the opaque nonce, so a future refactor flipping it to
|
||||
* non-httpOnly cannot leak the user id into request logs / network
|
||||
@@ -39,13 +51,72 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
// CSRF check, leg 1: URL state must match the cookie state byte-for-byte.
|
||||
// Timing-safe comparison so a probe can't recover the cookie bytes via
|
||||
// response-time analysis.
|
||||
// response-time analysis. This runs BEFORE the atomic delete so a
|
||||
// probe with arbitrary state values can't grief legitimate ledger
|
||||
// rows by triggering deletes for nonces it doesn't legitimately
|
||||
// hold the cookie for.
|
||||
//
|
||||
// v1.4.49 — reason tags now distinguish the four post-delete branches
|
||||
// so operators reading the audit trail can tell `csrf1` (URL/cookie
|
||||
// mismatch, short-circuit before delete), `replay` (P2025 — nonce
|
||||
// already consumed), `expired` (valid row but TTL elapsed), and
|
||||
// `cross_user` (valid row but session userId mismatch) apart. A
|
||||
// matching `meta.reason` Wide-Event annotation carries the same so
|
||||
// operators can grep without DB-shell.
|
||||
if (
|
||||
!state ||
|
||||
!storedState ||
|
||||
state.length !== storedState.length ||
|
||||
!timingSafeEqual(Buffer.from(state), Buffer.from(storedState))
|
||||
) {
|
||||
annotate({ meta: { reason: "csrf1" } });
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings/integrations?withings=error&reason=csrf1",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// CSRF check, leg 2: atomically consume the ledger row. Prisma
|
||||
// `delete` returns the row on success so `expiresAt` + `userId`
|
||||
// checks still run against the consumed payload. P2025 ("record
|
||||
// not found") means the nonce was already consumed by a prior
|
||||
// callback (a replay) or never existed — both land on the same
|
||||
// error redirect. Atomic at the Postgres row level: two concurrent
|
||||
// callbacks with the same nonce can no longer both pass the
|
||||
// lookup before either issues the delete.
|
||||
let stateRow: { userId: string; expiresAt: Date } | null = null;
|
||||
try {
|
||||
stateRow = await prisma.withingsOAuthState.delete({
|
||||
where: { nonce: state },
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === "P2025"
|
||||
) {
|
||||
// Row already consumed (replay) or never existed.
|
||||
annotate({ meta: { reason: "replay" } });
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings/integrations?withings=error&reason=replay",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
}
|
||||
// A real infra problem (connection drop, integrity violation)
|
||||
// shouldn't be silently swallowed. Surface it to the Wide Event
|
||||
// so the audit trail captures it, then bounce to the error
|
||||
// page. Re-throwing would land on the apiHandler's 500 path and
|
||||
// strand the user without a clean redirect.
|
||||
//
|
||||
// v1.4.49 QA-2 — the warning interpolates `err.name` rather than
|
||||
// `${err}` so a Prisma error message echoing the offending value
|
||||
// (e.g. the raw nonce) into the wide-event log cannot leak it.
|
||||
const errName = err instanceof Error ? err.name : "unknown";
|
||||
getEvent()?.addWarning(`oauth-state-delete failed: ${errName}`);
|
||||
annotate({ meta: { reason: "state" } });
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings/integrations?withings=error&reason=state",
|
||||
@@ -54,42 +125,30 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
// CSRF check, leg 2: the nonce must resolve to a live, unexpired
|
||||
// ledger row whose `userId` matches the authenticated session.
|
||||
// Resolving the user from the row (rather than parsing the cookie
|
||||
// value) is the v1.4.43 audit recommendation — a future refactor
|
||||
// flipping the cookie to non-httpOnly cannot leak the user id this
|
||||
// way.
|
||||
const stateRow = await prisma.withingsOAuthState.findUnique({
|
||||
where: { nonce: state },
|
||||
});
|
||||
if (
|
||||
!stateRow ||
|
||||
stateRow.expiresAt <= new Date() ||
|
||||
stateRow.userId !== user.id
|
||||
) {
|
||||
// Single-use: stamp out the row whether the expiry tripped, the
|
||||
// user mismatched, or we found nothing. The user will retry from
|
||||
// `withings/connect` with a fresh nonce.
|
||||
if (stateRow) {
|
||||
await prisma.withingsOAuthState
|
||||
.delete({ where: { nonce: state } })
|
||||
.catch(() => {});
|
||||
}
|
||||
// Validity checks against the consumed row. The row is already
|
||||
// gone from the ledger by this point — single-use semantics —
|
||||
// whether the expiry tripped, the userId mismatched, or
|
||||
// everything looked good.
|
||||
if (stateRow.expiresAt <= new Date()) {
|
||||
annotate({ meta: { reason: "expired" } });
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings/integrations?withings=error&reason=state",
|
||||
"/settings/integrations?withings=error&reason=expired",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (stateRow.userId !== user.id) {
|
||||
annotate({ meta: { reason: "cross_user" } });
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings/integrations?withings=error&reason=cross_user",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
// Consume the row even on the no-code path so the nonce can't
|
||||
// be replayed by an attacker who scraped the redirect URL.
|
||||
await prisma.withingsOAuthState
|
||||
.delete({ where: { nonce: state } })
|
||||
.catch(() => {});
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings/integrations?withings=error&reason=nocode",
|
||||
@@ -101,9 +160,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const creds = await getUserWithingsCredentials(user.id);
|
||||
if (!creds) {
|
||||
await prisma.withingsOAuthState
|
||||
.delete({ where: { nonce: state } })
|
||||
.catch(() => {});
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings/integrations?withings=error&reason=nocreds",
|
||||
@@ -156,13 +212,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// don't write a fresh success-time — that's the next sync's job.
|
||||
await markReconnected(user.id, "withings");
|
||||
|
||||
// Consume the ledger row — single-use semantics. From this point
|
||||
// on a replay of the same `state` lands on the not-found branch
|
||||
// above and redirects to the error page.
|
||||
await prisma.withingsOAuthState
|
||||
.delete({ where: { nonce: state } })
|
||||
.catch(() => {});
|
||||
|
||||
const response = NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings/integrations?withings=connected",
|
||||
@@ -173,12 +222,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
return response;
|
||||
} catch (err) {
|
||||
getEvent()?.setError(err);
|
||||
// Consume the row on the failure path too — the user is going to
|
||||
// retry from `withings/connect` which mints a fresh nonce, and a
|
||||
// stranded row buys us nothing.
|
||||
await prisma.withingsOAuthState
|
||||
.delete({ where: { nonce: state } })
|
||||
.catch(() => {});
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings/integrations?withings=error&reason=token",
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* v1.4.48 L5 + L14 — GET /api/withings/connect
|
||||
*
|
||||
* Pins:
|
||||
* 1. Happy path: mints a nonce, creates a `WithingsOAuthState` row,
|
||||
* redirects to the Withings authorize URL with the state cookie
|
||||
* attached.
|
||||
* 2. Rate-limit: the 11th call in a 60 s window redirects to
|
||||
* `/settings/integrations?withings=error&reason=rate_limited`
|
||||
* and does not call `prisma.withingsOAuthState.create`.
|
||||
* 3. Row-create failure: a Prisma error inside the try/catch
|
||||
* redirects to
|
||||
* `/settings/integrations?withings=error&reason=connect`
|
||||
* instead of bubbling a 500.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({
|
||||
user: { id: "u-1" },
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
withingsOAuthState: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/withings/credentials", () => ({
|
||||
getUserWithingsCredentials: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/withings/client", () => ({
|
||||
getAuthorizationUrl: vi.fn(
|
||||
() => "https://account.withings.com/oauth2_user/authorize2?state=nonce-stub",
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/withings/oauth-state", () => ({
|
||||
WITHINGS_OAUTH_STATE_COOKIE: "withings_oauth_state",
|
||||
WITHINGS_OAUTH_STATE_TTL_MS: 10 * 60 * 1000,
|
||||
mintWithingsOAuthStateNonce: vi.fn(() => "nonce-stub"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => ({
|
||||
setError: vi.fn(),
|
||||
addWarning: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
|
||||
|
||||
function connectRequest(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/withings/connect", {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(prisma.withingsOAuthState.create).mockReset();
|
||||
vi.mocked(prisma.withingsOAuthState.create).mockResolvedValue(
|
||||
{} as never,
|
||||
);
|
||||
vi.mocked(getUserWithingsCredentials).mockResolvedValue({
|
||||
clientId: "client-id",
|
||||
clientSecret: "client-secret",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/withings/connect", () => {
|
||||
it("redirects to the Withings authorize URL on the happy path", async () => {
|
||||
const response = await GET(connectRequest());
|
||||
|
||||
expect(response.status).toBe(307);
|
||||
expect(response.headers.get("location")).toContain(
|
||||
"account.withings.com/oauth2_user/authorize2",
|
||||
);
|
||||
expect(prisma.withingsOAuthState.create).toHaveBeenCalledTimes(1);
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(
|
||||
"withings:connect:u-1",
|
||||
10,
|
||||
60_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("redirects to reason=rate_limited when the 11th call within 60 s is rejected", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
|
||||
const response = await GET(connectRequest());
|
||||
|
||||
expect(response.status).toBe(307);
|
||||
const location = response.headers.get("location") ?? "";
|
||||
expect(location).toContain("/settings/integrations");
|
||||
expect(location).toContain("withings=error");
|
||||
expect(location).toContain("reason=rate_limited");
|
||||
expect(prisma.withingsOAuthState.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("redirects to reason=connect when the row create throws", async () => {
|
||||
vi.mocked(prisma.withingsOAuthState.create).mockRejectedValueOnce(
|
||||
new Error("Prisma transient failure"),
|
||||
);
|
||||
|
||||
const response = await GET(connectRequest());
|
||||
|
||||
expect(response.status).toBe(307);
|
||||
const location = response.headers.get("location") ?? "";
|
||||
expect(location).toContain("/settings/integrations");
|
||||
expect(location).toContain("withings=error");
|
||||
expect(location).toContain("reason=connect");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getAuthorizationUrl } from "@/lib/withings/client";
|
||||
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
|
||||
import {
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
WITHINGS_OAUTH_STATE_TTL_MS,
|
||||
mintWithingsOAuthStateNonce,
|
||||
} from "@/lib/withings/oauth-state";
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* Redirects the user to Withings OAuth authorization page.
|
||||
@@ -21,11 +22,39 @@ import { NextResponse } from "next/server";
|
||||
* nonce, and the callback resolves the user via the row's `userId`
|
||||
* column. Closes the v1.4.43 security audit L-1 finding where the
|
||||
* user id was recoverable from log entries / network captures.
|
||||
*
|
||||
* v1.4.48 L5 + L14 — endpoint is now rate-limited per-user (10 calls
|
||||
* per 60 s) so a logged-in user with valid creds can't spam ledger
|
||||
* rows for the full 10-min TTL window. The row-create call is wrapped
|
||||
* in try/catch; a transient DB glitch now lands on the existing
|
||||
* `/settings/integrations?withings=error&reason=connect` surface
|
||||
* instead of bubbling a 500. Both rate-limit and create-failure paths
|
||||
* redirect rather than returning JSON because the entry point is a
|
||||
* browser navigation — a 429 envelope would surface as a blank page.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
|
||||
const CONNECT_RATE_LIMIT = 10;
|
||||
const CONNECT_WINDOW_MS = 60_000;
|
||||
|
||||
export const GET = apiHandler(async (req: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "withings.connect" } });
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`withings:connect:${user.id}`,
|
||||
CONNECT_RATE_LIMIT,
|
||||
CONNECT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
annotate({ action: { name: "withings.connect.rate_limited" } });
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings/integrations?withings=error&reason=rate_limited",
|
||||
req.url,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const creds = await getUserWithingsCredentials(user.id);
|
||||
if (!creds) {
|
||||
return apiError(
|
||||
@@ -35,13 +64,24 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
|
||||
const nonce = mintWithingsOAuthStateNonce();
|
||||
await prisma.withingsOAuthState.create({
|
||||
data: {
|
||||
nonce,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + WITHINGS_OAUTH_STATE_TTL_MS),
|
||||
},
|
||||
});
|
||||
try {
|
||||
await prisma.withingsOAuthState.create({
|
||||
data: {
|
||||
nonce,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + WITHINGS_OAUTH_STATE_TTL_MS),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
getEvent()?.setError(err);
|
||||
annotate({ action: { name: "withings.connect.create_failed" } });
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings/integrations?withings=error&reason=connect",
|
||||
req.url,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const url = getAuthorizationUrl(nonce, creds);
|
||||
|
||||
|
||||
Reference in new issue
Block a user