diff --git a/src/app/api/integrations/moodlog/webhook/__tests__/route.test.ts b/src/app/api/integrations/moodlog/webhook/__tests__/route.test.ts new file mode 100644 index 000000000..a54435f6d --- /dev/null +++ b/src/app/api/integrations/moodlog/webhook/__tests__/route.test.ts @@ -0,0 +1,267 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +// --- Module-boundary mocks must come before importing the route. --- + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn() }, + user: { findMany: vi.fn() }, + moodEntry: { + upsert: vi.fn(), + deleteMany: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), +})); + +vi.mock("@/lib/moodlog-secret", () => ({ + readMoodLogSecret: vi.fn(), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(() => ({ setAuth: vi.fn(), addWarning: vi.fn() })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { readMoodLogSecret } from "@/lib/moodlog-secret"; + +const VALID_PAYLOAD = { + event: "mood.created" as const, + timestamp: "2026-05-08T12:00:00.000Z", + entry: { + date: "2026-05-08", + time: "2026-05-08T12:00:00.000Z", + mood: "GUT" as const, + score: 4, + tags: ["walk", "sun"], + loggedVia: "WEB" as const, + }, +}; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 30, + resetAt: Date.now() + 60_000, + } as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + moodLogGlobal: true, + } as never); + vi.mocked(prisma.user.findMany).mockResolvedValue([ + { id: "user-1", moodLogWebhookSecret: "encrypted-blob-1" }, + ] as never); + vi.mocked(readMoodLogSecret).mockReturnValue("plaintext-secret"); + vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({} as never); + vi.mocked(prisma.moodEntry.deleteMany).mockResolvedValue({ + count: 0, + } as never); +}); + +function jsonRequest( + body: unknown, + headers: Record = {}, +): NextRequest { + return new NextRequest("http://localhost/api/integrations/moodlog/webhook", { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify(body), + }); +} + +describe("POST /api/integrations/moodlog/webhook", () => { + it("returns 401 when X-Webhook-Secret header is missing", async () => { + const res = await POST(jsonRequest(VALID_PAYLOAD)); + expect(res.status).toBe(401); + const body = (await res.json()) as { error: string }; + expect(body.error).toMatch(/missing/i); + expect(prisma.moodEntry.upsert).not.toHaveBeenCalled(); + }); + + it("returns 401 when no enabled user matches the supplied secret", async () => { + // The single candidate decrypts to a different plaintext. + vi.mocked(readMoodLogSecret).mockReturnValue("different-plaintext"); + + const res = await POST( + jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }), + ); + expect(res.status).toBe(401); + expect(readMoodLogSecret).toHaveBeenCalledWith("encrypted-blob-1"); + expect(prisma.moodEntry.upsert).not.toHaveBeenCalled(); + }); + + it("decrypts each candidate via readMoodLogSecret and matches timing-safely", async () => { + vi.mocked(prisma.user.findMany).mockResolvedValueOnce([ + { id: "user-A", moodLogWebhookSecret: "blob-A" }, + { id: "user-B", moodLogWebhookSecret: "blob-B" }, + ] as never); + + // First candidate decrypts to a wrong value, second decrypts to the + // submitted secret. The route must walk the list and pick user-B. + vi.mocked(readMoodLogSecret) + .mockReturnValueOnce("nope") + .mockReturnValueOnce("plaintext-secret"); + + const res = await POST( + jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }), + ); + + expect(res.status).toBe(200); + expect(readMoodLogSecret).toHaveBeenNthCalledWith(1, "blob-A"); + expect(readMoodLogSecret).toHaveBeenNthCalledWith(2, "blob-B"); + const upsertArgs = vi.mocked(prisma.moodEntry.upsert).mock.calls[0][0]; + // The route must use the matched user's id, not the first candidate's. + expect( + (upsertArgs.where as { userId_date_moodLoggedAt: { userId: string } }) + .userId_date_moodLoggedAt.userId, + ).toBe("user-B"); + }); + + it("happy path: valid secret + mood.created upserts a mood entry tagged MOODLOG", async () => { + const res = await POST( + jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }), + ); + expect(res.status).toBe(200); + + expect(prisma.moodEntry.upsert).toHaveBeenCalledTimes(1); + const args = vi.mocked(prisma.moodEntry.upsert).mock.calls[0][0]; + expect(args).toMatchObject({ + where: { + userId_date_moodLoggedAt: { + userId: "user-1", + date: "2026-05-08", + moodLoggedAt: new Date("2026-05-08T12:00:00.000Z"), + }, + }, + update: { + mood: "GUT", + score: 4, + tags: JSON.stringify(["walk", "sun"]), + source: "MOODLOG", + }, + create: { + userId: "user-1", + date: "2026-05-08", + mood: "GUT", + score: 4, + tags: JSON.stringify(["walk", "sun"]), + source: "MOODLOG", + moodLoggedAt: new Date("2026-05-08T12:00:00.000Z"), + }, + }); + }); + + it("idempotency: a duplicate mood.updated event hits upsert with the same composite key, so the DB no-ops", async () => { + const updated = { ...VALID_PAYLOAD, event: "mood.updated" as const }; + + const r1 = await POST( + jsonRequest(updated, { "x-webhook-secret": "plaintext-secret" }), + ); + const r2 = await POST( + jsonRequest(updated, { "x-webhook-secret": "plaintext-secret" }), + ); + + expect(r1.status).toBe(200); + expect(r2.status).toBe(200); + expect(prisma.moodEntry.upsert).toHaveBeenCalledTimes(2); + const wheres = vi.mocked(prisma.moodEntry.upsert).mock.calls.map( + (c) => + ( + c[0].where as { + userId_date_moodLoggedAt: { + userId: string; + date: string; + moodLoggedAt: Date; + }; + } + ).userId_date_moodLoggedAt, + ); + // Both calls target the same composite key — Postgres ON CONFLICT + // makes the second upsert a no-op write. + expect(wheres[0]).toEqual(wheres[1]); + }); + + it("mood.deleted removes any matching entry rather than upserting", async () => { + const deletedPayload = { + ...VALID_PAYLOAD, + event: "mood.deleted" as const, + }; + const res = await POST( + jsonRequest(deletedPayload, { + "x-webhook-secret": "plaintext-secret", + }), + ); + expect(res.status).toBe(200); + expect(prisma.moodEntry.upsert).not.toHaveBeenCalled(); + expect(prisma.moodEntry.deleteMany).toHaveBeenCalledWith({ + where: { + userId: "user-1", + date: "2026-05-08", + moodLoggedAt: new Date("2026-05-08T12:00:00.000Z"), + }, + }); + }); + + it("webhook.test ping short-circuits to 200 with no DB writes", async () => { + const res = await POST( + jsonRequest( + { event: "webhook.test" }, + { "x-webhook-secret": "plaintext-secret" }, + ), + ); + expect(res.status).toBe(200); + expect(prisma.moodEntry.upsert).not.toHaveBeenCalled(); + expect(prisma.moodEntry.deleteMany).not.toHaveBeenCalled(); + }); + + it("rejects payloads that don't match the schema with 400", async () => { + const bad = { + event: "mood.created", + timestamp: "2026-05-08T12:00:00.000Z", + // entry missing required fields + entry: { date: "not-a-date", time: "x", mood: "BAD", score: 0 }, + }; + const res = await POST( + jsonRequest(bad, { "x-webhook-secret": "plaintext-secret" }), + ); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toMatch(/invalid payload/i); + expect(prisma.moodEntry.upsert).not.toHaveBeenCalled(); + }); + + it("returns 403 when the global moodLog toggle is disabled", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ + moodLogGlobal: false, + } as never); + const res = await POST( + jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }), + ); + expect(res.status).toBe(403); + expect(prisma.user.findMany).not.toHaveBeenCalled(); + }); + + it("returns 429 when rate limit is exceeded — no secret lookup performed", async () => { + vi.mocked(checkRateLimit).mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + } as never); + const res = await POST( + jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }), + ); + expect(res.status).toBe(429); + expect(prisma.user.findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts new file mode 100644 index 000000000..e33c3622c --- /dev/null +++ b/src/app/api/telegram/webhook/__tests__/route.test.ts @@ -0,0 +1,616 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; + +// --- Module-boundary mocks must come before importing the route. --- + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findFirst: vi.fn() }, + medication: { + findFirst: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + }, + medicationIntakeEvent: { + create: vi.fn(), + findFirst: vi.fn(), + }, + telegramReminderMessage: { + deleteMany: vi.fn(), + }, + telegramScheduledDeletion: { + createMany: vi.fn(), + }, + $transaction: vi.fn(), + }, +})); + +vi.mock("@/lib/crypto", () => ({ + decrypt: vi.fn((value: string) => `decrypted:${value}`), +})); + +vi.mock("@/lib/telegram", () => ({ + answerTelegramCallbackQuery: vi.fn().mockResolvedValue(undefined), + deleteMessage: vi.fn().mockResolvedValue(undefined), + sendTelegramMessage: vi.fn().mockResolvedValue({ messageId: 999, ok: true }), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), + rateLimitHeaders: vi.fn(() => ({})), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(() => ({ + setAuth: vi.fn(), + addWarning: vi.fn(), + })), +})); + +import { POST, GET } from "../route"; +import { prisma } from "@/lib/db"; +import { + answerTelegramCallbackQuery, + deleteMessage, + sendTelegramMessage, +} from "@/lib/telegram"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const ORIGINAL_SECRET = process.env.TELEGRAM_WEBHOOK_SECRET; + +const TG_USER = { + id: "user-1", + telegramBotToken: "ENC(token-blob)", + locale: "en", +}; + +const MEDICATION = { id: "med-1", name: "Ramipril" }; + +beforeEach(() => { + vi.resetAllMocks(); + process.env.TELEGRAM_WEBHOOK_SECRET = "sekret"; + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 120, + resetAt: Date.now() + 60_000, + } as never); + vi.mocked(prisma.user.findFirst).mockResolvedValue(TG_USER as never); + vi.mocked(prisma.medication.findFirst).mockResolvedValue(MEDICATION as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medication.update).mockResolvedValue({} as never); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(null); + vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({} as never); + vi.mocked(prisma.telegramReminderMessage.deleteMany).mockResolvedValue({ + count: 0, + } as never); + vi.mocked(prisma.telegramScheduledDeletion.createMany).mockResolvedValue({ + count: 0, + } as never); + vi.mocked(prisma.$transaction).mockResolvedValue([] as never); + vi.mocked(answerTelegramCallbackQuery).mockResolvedValue(undefined as never); + vi.mocked(deleteMessage).mockResolvedValue(undefined as never); + vi.mocked(sendTelegramMessage).mockResolvedValue({ + messageId: 999, + ok: true, + } as never); +}); + +afterEach(() => { + if (ORIGINAL_SECRET === undefined) delete process.env.TELEGRAM_WEBHOOK_SECRET; + else process.env.TELEGRAM_WEBHOOK_SECRET = ORIGINAL_SECRET; +}); + +function tgRequest( + body: unknown, + headers: Record = { + "x-telegram-bot-api-secret-token": "sekret", + }, +): NextRequest { + return new NextRequest("http://localhost/api/telegram/webhook", { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify(body), + }); +} + +function callbackUpdate(data: string, overrides: Record = {}) { + return { + update_id: 100, + callback_query: { + id: "cb-1", + data, + from: { id: 7777 }, + message: { + message_id: 555, + chat: { id: 7777 }, + }, + ...overrides, + }, + }; +} + +describe("Telegram webhook — secret verification", () => { + it("returns 401 when the X-Telegram-Bot-Api-Secret-Token header is missing", async () => { + const res = await POST(tgRequest(callbackUpdate("taken:med-1"), {})); + expect(res.status).toBe(401); + expect(prisma.user.findFirst).not.toHaveBeenCalled(); + }); + + it("returns 401 when the header is present but does not match the env secret", async () => { + const res = await POST( + tgRequest(callbackUpdate("taken:med-1"), { + "x-telegram-bot-api-secret-token": "WRONG", + }), + ); + expect(res.status).toBe(401); + expect(prisma.user.findFirst).not.toHaveBeenCalled(); + }); + + it("returns 401 when TELEGRAM_WEBHOOK_SECRET env var is unset (defence in depth)", async () => { + delete process.env.TELEGRAM_WEBHOOK_SECRET; + const res = await POST( + tgRequest(callbackUpdate("taken:med-1"), { + "x-telegram-bot-api-secret-token": "anything", + }), + ); + expect(res.status).toBe(401); + }); + + it("returns 200 with a valid header secret (happy path baseline)", async () => { + const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); + expect(res.status).toBe(200); + }); +}); + +describe("Telegram webhook — rate limit", () => { + it("returns 429 when rate-limited and never reads the secret/user", async () => { + vi.mocked(checkRateLimit).mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + } as never); + const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); + expect(res.status).toBe(429); + expect(prisma.user.findFirst).not.toHaveBeenCalled(); + }); +}); + +describe("Telegram webhook — callback dispatch", () => { + it("'taken:' creates a MedicationIntakeEvent + clears snoozedUntil + acks the callback", async () => { + const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); + expect(res.status).toBe(200); + + // $transaction is invoked with [create-intake, update-medication]. Inspect + // its argument list for both writes. + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + const txOps = vi.mocked(prisma.$transaction).mock.calls[0][0] as unknown; + expect(Array.isArray(txOps)).toBe(true); + + // The route calls prisma.medicationIntakeEvent.create + medication.update + // synchronously while building the transaction array, so these mocks + // record the arguments the way it built them. + expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); + const intakeArgs = vi.mocked(prisma.medicationIntakeEvent.create).mock + .calls[0][0]; + expect(intakeArgs.data).toMatchObject({ + userId: "user-1", + medicationId: "med-1", + skipped: false, + source: "REMINDER", + }); + expect(intakeArgs.data.idempotencyKey).toMatch( + /^telegram:cb:7777:555:med-1/, + ); + + expect(prisma.medication.update).toHaveBeenCalledWith({ + where: { id: "med-1" }, + data: { snoozedUntil: null }, + }); + + expect(answerTelegramCallbackQuery).toHaveBeenCalledTimes(1); + expect(deleteMessage).toHaveBeenCalledWith( + "decrypted:ENC(token-blob)", + "7777", + 555, + ); + }); + + it("'taken:' is idempotent — replaying the same callback with the same message_id does NOT create a second intake event", async () => { + // First call: existing-row lookup misses → write happens. + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce( + null, + ); + await POST(tgRequest(callbackUpdate("taken:med-1"))); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + + // Second call: existing-row lookup hits → no second write. + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce({ + id: "intake-existing", + } as never); + const res2 = await POST(tgRequest(callbackUpdate("taken:med-1"))); + expect(res2.status).toBe(200); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); // unchanged + }); + + it("'snooze::60' updates the medication's snoozedUntil and answers with the locale-translated message", async () => { + const res = await POST(tgRequest(callbackUpdate("snooze:med-1:60"))); + expect(res.status).toBe(200); + + expect(prisma.medication.update).toHaveBeenCalledTimes(1); + const updateArgs = vi.mocked(prisma.medication.update).mock.calls[0][0]; + expect(updateArgs.where).toEqual({ id: "med-1" }); + const snoozedUntil = (updateArgs.data as { snoozedUntil: Date }) + .snoozedUntil; + expect(snoozedUntil).toBeInstanceOf(Date); + // Allow some clock drift but verify ~60 minutes ahead. + const deltaMs = snoozedUntil.getTime() - Date.now(); + expect(deltaMs).toBeGreaterThan(55 * 60_000); + expect(deltaMs).toBeLessThan(65 * 60_000); + + expect(answerTelegramCallbackQuery).toHaveBeenCalledTimes(1); + const ackText = vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2]; + expect(ackText).toMatch(/Ramipril/); + expect(ackText).toMatch(/1 hour/i); + }); + + it("'skip:' creates an intake event with skipped=true and snoozes through end-of-day", async () => { + const res = await POST(tgRequest(callbackUpdate("skip:med-1"))); + expect(res.status).toBe(200); + + expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); + const intakeArgs = vi.mocked(prisma.medicationIntakeEvent.create).mock + .calls[0][0]; + expect(intakeArgs.data).toMatchObject({ + userId: "user-1", + medicationId: "med-1", + skipped: true, + takenAt: null, + source: "REMINDER", + }); + expect(intakeArgs.data.idempotencyKey).toMatch( + /^telegram:skip:7777:555:med-1/, + ); + + const updateArgs = vi.mocked(prisma.medication.update).mock.calls[0][0]; + const snoozedUntil = (updateArgs.data as { snoozedUntil: Date }) + .snoozedUntil; + expect(snoozedUntil.getHours()).toBe(23); + expect(snoozedUntil.getMinutes()).toBe(59); + }); + + it("'ack:' answers the callback without creating an intake event", async () => { + const res = await POST(tgRequest(callbackUpdate("ack:med-1"))); + expect(res.status).toBe(200); + + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.$transaction).not.toHaveBeenCalled(); + expect(answerTelegramCallbackQuery).toHaveBeenCalledTimes(1); + expect(deleteMessage).toHaveBeenCalledTimes(1); + }); + + it("unknown callback action is acked with an 'unknown action' message and writes nothing", async () => { + const res = await POST(tgRequest(callbackUpdate("totally:bogus"))); + expect(res.status).toBe(200); + + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.medication.update).not.toHaveBeenCalled(); + expect(prisma.$transaction).not.toHaveBeenCalled(); + expect(answerTelegramCallbackQuery).toHaveBeenCalledTimes(1); + const ackText = vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2]; + expect(ackText).toMatch(/unknown action/i); + }); + + it("ignores callbacks from chats with no enrolled Telegram user (no answer, no DB writes)", async () => { + vi.mocked(prisma.user.findFirst).mockResolvedValueOnce(null); + const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); + expect(res.status).toBe(200); + expect(answerTelegramCallbackQuery).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + }); + + it("rejects 'taken' for an inactive medication and acks with the localised error", async () => { + vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(null); + const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); + expect(res.status).toBe(200); + + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + const ackText = vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2]; + expect(ackText).toMatch(/medication.*not.*found.*inactive/i); + }); + + it("'snooze' with an empty medication id acks with errorInvalidAction (no writes)", async () => { + const res = await POST(tgRequest(callbackUpdate("snooze::60"))); + expect(res.status).toBe(200); + expect(prisma.medication.update).not.toHaveBeenCalled(); + const ackText = vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2]; + expect(ackText).toMatch(/invalid action/i); + }); + + it("'snooze' for a missing medication acks with medicationNotFound (no update)", async () => { + vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(null); + const res = await POST(tgRequest(callbackUpdate("snooze:med-X:60"))); + expect(res.status).toBe(200); + expect(prisma.medication.update).not.toHaveBeenCalled(); + const ackText = vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2]; + expect(ackText).toMatch(/medication not found/i); + }); + + it("'add::umid:' creates an intake event and deletes both bot + user messages", async () => { + const res = await POST(tgRequest(callbackUpdate("add:med-1:umid:1234"))); + expect(res.status).toBe(200); + + expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); + const intakeArgs = vi.mocked(prisma.medicationIntakeEvent.create).mock + .calls[0][0]; + expect(intakeArgs.data.idempotencyKey).toMatch( + /^telegram:add:7777:555:med-1/, + ); + + // Bot's selection message + user's /add command message both deleted. + const deleteCalls = vi.mocked(deleteMessage).mock.calls; + const ids = deleteCalls.map((c) => c[2]); + expect(ids).toContain(555); + expect(ids).toContain(1234); + }); + + it("'cancel_add:umid:1234' deletes both messages and acks with the cancelled message", async () => { + const res = await POST(tgRequest(callbackUpdate("cancel_add:umid:1234"))); + expect(res.status).toBe(200); + + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + const ids = vi.mocked(deleteMessage).mock.calls.map((c) => c[2]); + expect(ids).toContain(555); + expect(ids).toContain(1234); + const ackText = vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2]; + expect(ackText).toMatch(/cancelled/i); + }); +}); + +describe("Telegram webhook — text-message dispatch", () => { + it("returns 200 'ok' for /help and replies with help text", async () => { + const update = { + update_id: 200, + message: { + message_id: 11, + text: "/help", + chat: { id: 7777 }, + }, + }; + const res = await POST(tgRequest(update)); + expect(res.status).toBe(200); + expect(sendTelegramMessage).toHaveBeenCalledTimes(1); + const text = vi.mocked(sendTelegramMessage).mock.calls[0][2]; + expect(text).toMatch(/available commands/i); + }); + + it("ignores unrelated text — never calls sendTelegramMessage", async () => { + const update = { + update_id: 201, + message: { + message_id: 12, + text: "the weather is nice today", + chat: { id: 7777 }, + }, + }; + const res = await POST(tgRequest(update)); + expect(res.status).toBe(200); + expect(sendTelegramMessage).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + }); + + it("greeting like 'hi' echoes a friendly reply", async () => { + const res = await POST( + tgRequest({ + update_id: 202, + message: { + message_id: 13, + text: "hi", + chat: { id: 7777 }, + }, + }), + ); + expect(res.status).toBe(200); + expect(sendTelegramMessage).toHaveBeenCalledTimes(1); + expect(vi.mocked(sendTelegramMessage).mock.calls[0][2]).toMatch(/Hi!/); + }); + + it("/add with no active medications replies with the 'no active' message", async () => { + vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([] as never); + const res = await POST( + tgRequest({ + update_id: 203, + message: { + message_id: 14, + text: "/add", + chat: { id: 7777 }, + }, + }), + ); + expect(res.status).toBe(200); + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(vi.mocked(sendTelegramMessage).mock.calls[0][2]).toMatch( + /no active medication/i, + ); + }); + + it("/add with exactly one active medication marks it taken without prompting", async () => { + vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([ + { id: "med-only", name: "Aspirin", dose: "100mg" }, + ] as never); + const res = await POST( + tgRequest({ + update_id: 204, + message: { + message_id: 15, + text: "/add", + chat: { id: 7777 }, + }, + }), + ); + expect(res.status).toBe(200); + // markMedicationTaken → findFirst on active medication + expect(prisma.medication.findFirst).toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); + }); + + it("/add with multiple medications sends a keyboard listing them with cancel_add", async () => { + vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([ + { id: "med-a", name: "Aspirin", dose: "100mg" }, + { id: "med-b", name: "Bisoprolol", dose: "5mg" }, + ] as never); + + const res = await POST( + tgRequest({ + update_id: 205, + message: { + message_id: 16, + text: "/add", + chat: { id: 7777 }, + }, + }), + ); + expect(res.status).toBe(200); + + expect(sendTelegramMessage).toHaveBeenCalledTimes(1); + const opts = vi.mocked(sendTelegramMessage).mock.calls[0][3] as { + replyMarkup: { inline_keyboard: { callback_data: string }[][] }; + }; + const flatData = opts.replyMarkup.inline_keyboard + .flat() + .map((b) => b.callback_data); + expect(flatData).toEqual( + expect.arrayContaining([ + "add:med-a:umid:16", + "add:med-b:umid:16", + "cancel_add:umid:16", + ]), + ); + // No intake yet — user still has to choose. + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + }); + + it("'taken Ramipril' (text command) creates an intake event for the matched medication", async () => { + vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce({ + id: "med-1", + } as never); + // Second findFirst inside markMedicationTaken + vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce({ + id: "med-1", + name: "Ramipril", + } as never); + + const res = await POST( + tgRequest({ + update_id: 206, + message: { + message_id: 17, + text: "taken Ramipril", + chat: { id: 7777 }, + }, + }), + ); + expect(res.status).toBe(200); + expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); + const intakeArgs = vi.mocked(prisma.medicationIntakeEvent.create).mock + .calls[0][0]; + expect(intakeArgs.data.idempotencyKey).toMatch(/^telegram:text:206:med-1/); + }); + + it("'taken' (no name) with no clearly matching medication asks for a medication name", async () => { + // findMany returns 2 → ambiguous → ask + vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([ + { id: "med-a", name: "Aspirin" }, + { id: "med-b", name: "Bisoprolol" }, + ] as never); + const res = await POST( + tgRequest({ + update_id: 207, + message: { + message_id: 18, + text: "taken", + chat: { id: 7777 }, + }, + }), + ); + expect(res.status).toBe(200); + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(vi.mocked(sendTelegramMessage).mock.calls[0][2]).toMatch( + /specify a medication/i, + ); + }); + + it("'taken UnknownMed' replies with the medication-not-found message and writes nothing", async () => { + // First findFirst (lookup by name) returns null + vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(null); + + const res = await POST( + tgRequest({ + update_id: 208, + message: { + message_id: 19, + text: "taken UnknownMed", + chat: { id: 7777 }, + }, + }), + ); + expect(res.status).toBe(200); + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(vi.mocked(sendTelegramMessage).mock.calls[0][2]).toMatch( + /not found/i, + ); + }); + + it("returns 200 ignored when the body has no message and no callback_query", async () => { + const res = await POST( + tgRequest({ + update_id: 999, + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { status: string }; + expect(body.status).toBe("ignored"); + }); + + it("returns 200 ignored when the body has no update_id", async () => { + const res = await POST(tgRequest({ message: { text: "/help" } })); + expect(res.status).toBe(200); + const body = (await res.json()) as { status: string }; + expect(body.status).toBe("ignored"); + }); + + it("returns 200 'invalid json' when the request body is malformed JSON", async () => { + const req = new NextRequest("http://localhost/api/telegram/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-telegram-bot-api-secret-token": "sekret", + }, + body: "{not-valid", + }); + const res = await POST(req); + expect(res.status).toBe(200); + const body = (await res.json()) as { status: string }; + expect(body.status).toBe("invalid json"); + }); +}); + +describe("Telegram webhook — GET verification", () => { + it("returns 200 with a valid header secret", async () => { + const req = new NextRequest("http://localhost/api/telegram/webhook", { + headers: { "x-telegram-bot-api-secret-token": "sekret" }, + }); + const res = await GET(req); + expect(res.status).toBe(200); + }); + + it("returns 401 without a header secret", async () => { + const req = new NextRequest("http://localhost/api/telegram/webhook"); + const res = await GET(req); + expect(res.status).toBe(401); + }); +}); diff --git a/src/app/api/withings/webhook/__tests__/route.test.ts b/src/app/api/withings/webhook/__tests__/route.test.ts new file mode 100644 index 000000000..a0c96e202 --- /dev/null +++ b/src/app/api/withings/webhook/__tests__/route.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; + +// --- Module-boundary mocks must come before importing the route. --- + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + withingsConnection: { findFirst: vi.fn() }, + }, +})); + +vi.mock("@/lib/withings/sync", () => ({ + syncUserMeasurements: vi.fn(), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), + rateLimitHeaders: vi.fn(() => ({})), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(() => ({ + setAuth: vi.fn(), + addWarning: vi.fn(), + })), +})); + +import { POST, GET, HEAD } from "../route"; +import { prisma } from "@/lib/db"; +import { syncUserMeasurements } from "@/lib/withings/sync"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { getEvent } from "@/lib/logging/context"; + +const ORIGINAL_SECRET = process.env.WITHINGS_WEBHOOK_SECRET; + +beforeEach(() => { + vi.resetAllMocks(); + process.env.WITHINGS_WEBHOOK_SECRET = "test-secret"; + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 30, + resetAt: Date.now() + 60_000, + } as never); + vi.mocked(getEvent).mockReturnValue({ + setAuth: vi.fn(), + addWarning: vi.fn(), + } as never); + vi.mocked(syncUserMeasurements).mockResolvedValue(undefined as never); +}); + +afterEach(() => { + if (ORIGINAL_SECRET === undefined) delete process.env.WITHINGS_WEBHOOK_SECRET; + else process.env.WITHINGS_WEBHOOK_SECRET = ORIGINAL_SECRET; +}); + +function jsonRequest( + body: unknown, + headers: Record = {}, + search = "", +): NextRequest { + return new NextRequest(`http://localhost/api/withings/webhook${search}`, { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify(body), + }); +} + +function formRequest( + formBody: Record, + headers: Record = {}, +): NextRequest { + const usp = new URLSearchParams(formBody); + return new NextRequest("http://localhost/api/withings/webhook", { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + ...headers, + }, + body: usp.toString(), + }); +} + +describe("POST /api/withings/webhook", () => { + it("returns 401 with no secret header and no query secret", async () => { + const res = await POST(jsonRequest({ userid: "wu-1" })); + expect(res.status).toBe(401); + expect(syncUserMeasurements).not.toHaveBeenCalled(); + expect(prisma.withingsConnection.findFirst).not.toHaveBeenCalled(); + }); + + it("returns 401 when the X-Withings-Webhook-Secret header is wrong", async () => { + const res = await POST( + jsonRequest({ userid: "wu-1" }, { "x-withings-webhook-secret": "WRONG" }), + ); + expect(res.status).toBe(401); + expect(syncUserMeasurements).not.toHaveBeenCalled(); + }); + + it("returns 401 when WITHINGS_WEBHOOK_SECRET env var is unset, regardless of incoming header", async () => { + delete process.env.WITHINGS_WEBHOOK_SECRET; + const addWarning = vi.fn(); + vi.mocked(getEvent).mockReturnValue({ + setAuth: vi.fn(), + addWarning, + } as never); + + const res = await POST( + jsonRequest( + { userid: "wu-1" }, + { "x-withings-webhook-secret": "anything" }, + ), + ); + expect(res.status).toBe(401); + expect(addWarning).toHaveBeenCalledWith( + "WITHINGS_WEBHOOK_SECRET not configured", + ); + }); + + it("happy path: valid header secret + JSON body triggers sync for the resolved user", async () => { + vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({ + userId: "user-42", + withingsUserId: "wu-1", + } as never); + + const res = await POST( + jsonRequest( + { userid: "wu-1" }, + { "x-withings-webhook-secret": "test-secret" }, + ), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { status: string }; + expect(body.status).toBe("ok"); + + expect(prisma.withingsConnection.findFirst).toHaveBeenCalledWith({ + where: { withingsUserId: "wu-1" }, + }); + expect(syncUserMeasurements).toHaveBeenCalledTimes(1); + expect(syncUserMeasurements).toHaveBeenCalledWith("user-42"); + }); + + it("legacy ?secret=… query path still authorises and emits a Wide Event warning", async () => { + const addWarning = vi.fn(); + vi.mocked(getEvent).mockReturnValue({ + setAuth: vi.fn(), + addWarning, + } as never); + vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({ + userId: "user-99", + withingsUserId: "wu-2", + } as never); + + const res = await POST( + jsonRequest({ userid: "wu-2" }, {}, "?secret=test-secret"), + ); + expect(res.status).toBe(200); + expect(syncUserMeasurements).toHaveBeenCalledWith("user-99"); + expect(addWarning).toHaveBeenCalledWith( + expect.stringMatching(/legacy URL query/i), + ); + }); + + it("accepts form-encoded payloads (Withings legacy delivery format)", async () => { + vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({ + userId: "user-form", + withingsUserId: "wu-form", + } as never); + + const res = await POST( + formRequest( + { userid: "wu-form", appli: "1" }, + { "x-withings-webhook-secret": "test-secret" }, + ), + ); + expect(res.status).toBe(200); + expect(syncUserMeasurements).toHaveBeenCalledWith("user-form"); + }); + + it("returns 200 ignored when the body has no userid", async () => { + const res = await POST( + jsonRequest({}, { "x-withings-webhook-secret": "test-secret" }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { status: string }; + expect(body.status).toBe("ignored"); + expect(prisma.withingsConnection.findFirst).not.toHaveBeenCalled(); + expect(syncUserMeasurements).not.toHaveBeenCalled(); + }); + + it("returns 200 unknown_user when no WithingsConnection matches the wire userid", async () => { + vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce(null); + const res = await POST( + jsonRequest( + { userid: "wu-ghost" }, + { "x-withings-webhook-secret": "test-secret" }, + ), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { status: string }; + expect(body.status).toBe("unknown_user"); + expect(syncUserMeasurements).not.toHaveBeenCalled(); + }); + + it("returns 429 and skips sync when rate-limited", async () => { + vi.mocked(checkRateLimit).mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + } as never); + const res = await POST( + jsonRequest( + { userid: "wu-1" }, + { "x-withings-webhook-secret": "test-secret" }, + ), + ); + expect(res.status).toBe(429); + expect(syncUserMeasurements).not.toHaveBeenCalled(); + expect(prisma.withingsConnection.findFirst).not.toHaveBeenCalled(); + }); +}); + +describe("HEAD /api/withings/webhook (Withings URL verification)", () => { + it("returns 200 with a valid header secret", async () => { + const req = new NextRequest("http://localhost/api/withings/webhook", { + method: "HEAD", + headers: { "x-withings-webhook-secret": "test-secret" }, + }); + const res = await HEAD(req); + expect(res.status).toBe(200); + }); + + it("returns 401 with no secret", async () => { + const req = new NextRequest("http://localhost/api/withings/webhook", { + method: "HEAD", + }); + const res = await HEAD(req); + expect(res.status).toBe(401); + }); +}); + +describe("GET /api/withings/webhook", () => { + it("returns 200 with a valid header secret and 401 without", async () => { + const ok = await GET( + new NextRequest("http://localhost/api/withings/webhook", { + headers: { "x-withings-webhook-secret": "test-secret" }, + }), + ); + expect(ok.status).toBe(200); + + const fail = await GET( + new NextRequest("http://localhost/api/withings/webhook"), + ); + expect(fail.status).toBe(401); + }); +}); diff --git a/src/lib/auth/__tests__/audit.test.ts b/src/lib/auth/__tests__/audit.test.ts new file mode 100644 index 000000000..c9e7e23b6 --- /dev/null +++ b/src/lib/auth/__tests__/audit.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + auditLog: { + create: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/geo", () => ({ + lookupIpLocation: vi.fn(), +})); + +import { auditLog } from "../audit"; +import { prisma } from "@/lib/db"; +import { lookupIpLocation } from "@/lib/geo"; + +const ENTRY = { id: "audit-1" }; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.auditLog.create).mockResolvedValue(ENTRY as never); + vi.mocked(prisma.auditLog.update).mockResolvedValue(ENTRY as never); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +/** + * Wait for any micro/macrotask `Promise.race` resolutions to flush. + * Real timers are used because the Promise wrapping `setTimeout` resolves + * via the event loop — vi.useFakeTimers() would block the geo-lookup + * Promise from resolving in tests that need it. + */ +async function flush(): Promise { + // Two `setImmediate`-equivalent cycles are enough: one for `Promise.race` + // to resolve and one for the `.then(...)` continuation. + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("auditLog", () => { + it("creates an audit-log row with action, userId, JSON-stringified details, ipAddress and null location", async () => { + vi.mocked(lookupIpLocation).mockResolvedValueOnce(null); + + await auditLog("auth.login", { + userId: "user-7", + details: { reason: "passkey", browser: "firefox" }, + ipAddress: "203.0.113.5", + }); + + expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); + const args = vi.mocked(prisma.auditLog.create).mock.calls[0][0]; + expect(args.data).toEqual({ + action: "auth.login", + userId: "user-7", + details: JSON.stringify({ reason: "passkey", browser: "firefox" }), + ipAddress: "203.0.113.5", + }); + // Location is intentionally not set on insert — it's filled later by the + // geo lookup. The schema column itself is not in the create payload. + expect(args.data).not.toHaveProperty("location"); + }); + + it("persists null userId / null ipAddress / null details when none supplied", async () => { + await auditLog("auth.bearer.failure"); + + const args = vi.mocked(prisma.auditLog.create).mock.calls[0][0]; + expect(args.data).toEqual({ + action: "auth.bearer.failure", + userId: null, + details: null, + ipAddress: null, + }); + // No IP → no geo lookup enqueued. + expect(lookupIpLocation).not.toHaveBeenCalled(); + }); + + it("for auth.* actions with an ip address, runs geo lookup and updates the row with the resolved location", async () => { + vi.mocked(lookupIpLocation).mockResolvedValueOnce("Berlin, DE"); + + await auditLog("auth.login", { + userId: "user-8", + ipAddress: "8.8.8.8", + }); + await flush(); + + expect(lookupIpLocation).toHaveBeenCalledWith("8.8.8.8"); + expect(prisma.auditLog.update).toHaveBeenCalledTimes(1); + expect(prisma.auditLog.update).toHaveBeenCalledWith({ + where: { id: "audit-1" }, + data: { location: "Berlin, DE" }, + }); + }); + + it("does NOT update the row when geo lookup resolves to null", async () => { + vi.mocked(lookupIpLocation).mockResolvedValueOnce(null); + + await auditLog("auth.login", { + userId: "user-9", + ipAddress: "1.2.3.4", + }); + await flush(); + + expect(lookupIpLocation).toHaveBeenCalledOnce(); + expect(prisma.auditLog.update).not.toHaveBeenCalled(); + }); + + it("does NOT update the row when geo lookup outruns the 3s race timeout", async () => { + // Lookup that never resolves within 3 s — must lose the Promise.race. + let neverResolve: (value: string | null) => void; + vi.mocked(lookupIpLocation).mockImplementationOnce( + () => + new Promise((resolve) => { + neverResolve = resolve; + }), + ); + + vi.useFakeTimers(); + const promise = auditLog("auth.login", { + userId: "user-10", + ipAddress: "1.2.3.4", + }); + await promise; // create resolves immediately; race continues in background + + // Advance past the 3 s timeout — race resolves to null path. + await vi.advanceTimersByTimeAsync(3001); + vi.useRealTimers(); + await flush(); + + expect(prisma.auditLog.update).not.toHaveBeenCalled(); + // Caller stays unblocked — the awaited promise above already resolved. + // Resolve the dangling lookup so the test process exits cleanly. + neverResolve!("Tokyo, JP"); + await flush(); + }); + + it("non-auth actions never enqueue a geo lookup, even with an IP", async () => { + await auditLog("medication.create", { + userId: "user-11", + ipAddress: "203.0.113.99", + }); + await flush(); + + expect(prisma.auditLog.create).toHaveBeenCalledOnce(); + expect(lookupIpLocation).not.toHaveBeenCalled(); + expect(prisma.auditLog.update).not.toHaveBeenCalled(); + }); + + it("silently swallows geo-lookup throws — caller stays unblocked, no update emitted", async () => { + vi.mocked(lookupIpLocation).mockRejectedValueOnce( + new Error("upstream 503"), + ); + + // The auditLog promise itself must NOT reject. + await expect( + auditLog("auth.login", { + userId: "user-12", + ipAddress: "8.8.8.8", + }), + ).resolves.toBeUndefined(); + await flush(); + + expect(prisma.auditLog.update).not.toHaveBeenCalled(); + }); + + it("silently swallows update() throws (e.g. row deleted between create + update)", async () => { + vi.mocked(lookupIpLocation).mockResolvedValueOnce("Paris, FR"); + vi.mocked(prisma.auditLog.update).mockRejectedValueOnce( + new Error("row gone"), + ); + + await expect( + auditLog("auth.login", { + userId: "user-13", + ipAddress: "8.8.4.4", + }), + ).resolves.toBeUndefined(); + await flush(); + + expect(prisma.auditLog.update).toHaveBeenCalledOnce(); + }); +});