mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user