test(coverage): real-behaviour tests for audit + webhook routes (~80% line coverage) (#142)

Adds vitest unit tests covering the four zero-coverage webhook /
monitoring surfaces external systems hit. Each test mocks at the module
boundary (per existing project pattern in src/app/api/auth/login/
__tests__/native-token.test.ts) and asserts on actual call arguments
— no test theatre.

Per-file line coverage:
- src/lib/auth/audit.ts                                    100.00%
- src/app/api/withings/webhook/route.ts                     98.11%
- src/app/api/integrations/moodlog/webhook/route.ts         95.23%
- src/app/api/telegram/webhook/route.ts                     95.12%

Test count: +61 (494 -> 555).
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-08 14:30:03 +02:00
1 parent 382b4a0f9f
commit 6e74612efc
4 files changed
+1329

No files matched your search

@@ -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: <T extends (...args: unknown[]) => 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<string, string> = {},
): 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();
});
});
@@ -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: <T extends (...args: unknown[]) => 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<string, string> = {
"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<string, unknown> = {}) {
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:<medId>' 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:<medId>' 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:<medId>: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:<medId>' 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:<medId>' 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:<medId>:umid:<userMsgId>' 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);
});
});
@@ -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: <T extends (...args: unknown[]) => 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<string, string> = {},
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<string, string>,
headers: Record<string, string> = {},
): 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);
});
});
+186
View File
@@ -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<void> {
// 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<string | null>((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();
});
});