test(02-11): cover guardian notification channel safety

- Cover non-interactive Guardian payloads across delivery channels\n- Reject Telegram callbacks tied to managed records
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 05:37:05 +02:00
1 parent 59de92b62e
commit accb98e223
6 files changed
+136

No files matched your search

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery";
describe("managed Guardian notification payload safety", () => {
it("recognises only cross-account record delivery as managed Guardian delivery", () => {
expect(
isManagedGuardianDelivery({
eventType: "MEDICATION_REMINDER",
userId: "record-1",
recordUserId: "record-1",
recipientUserId: "guardian-1",
title: "t",
message: "m",
}),
).toBe(true);
expect(
isManagedGuardianDelivery({
eventType: "MEDICATION_REMINDER",
userId: "user-1",
recordUserId: "user-1",
recipientUserId: "user-1",
title: "t",
message: "m",
}),
).toBe(false);
});
});
@@ -287,6 +287,25 @@ describe("Telegram webhook — rate limit", () => {
});
describe("Telegram webhook — callback dispatch", () => {
it("rejects a callback tied to a managed Guardian reminder without mutating the record", async () => {
vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValueOnce({
medication: { userId: "managed-record" },
} as never);
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(res.status).toBe(200);
expect(prisma.medication.findFirst).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.medication.update).not.toHaveBeenCalled();
expect(deleteMessage).not.toHaveBeenCalled();
expect(answerTelegramCallbackQuery).toHaveBeenCalledWith(
"decrypted:ENC(token-blob)",
"cb-1",
"Invalid action.",
);
});
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);
@@ -164,6 +164,30 @@ describe("web-push reminder push — stable tag + badge", () => {
);
expect(body.tag).toBe("REMINDER");
});
it("uses an ordinary landing route without a persistent prompt for a managed Guardian", async () => {
findManyMock.mockResolvedValue([SUB]);
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(null, { status: 201 }),
);
await sendViaWebPush("guardian-1", {
eventType: "MEDICATION_REMINDER",
userId: "record-1",
recordUserId: "record-1",
recipientUserId: "guardian-1",
title: "Time for your dose",
message: "Ramipril 5mg",
urgent: true,
metadata: { url: "/medications/med-1" },
});
const body = JSON.parse(
generateRequestDetailsMock.mock.calls[0][1] as string,
);
expect(body.url).toBe("/");
expect(body.requireInteraction).toBe(false);
});
});
describe("clear-on-taken push", () => {
@@ -610,6 +610,32 @@ describe("sendViaApns — dispatcher fan-out", () => {
expect(note.payload.eventType).toBe("MEDICATION_REMINDER");
});
it("omits action metadata and categories for a managed Guardian", async () => {
vi.mocked(prisma.device.findMany).mockResolvedValueOnce([
{ id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" },
] as never);
sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] });
await sendViaApns("guardian-1", {
title: "t",
message: "m",
eventType: "MEDICATION_REMINDER",
userId: "record-1",
recordUserId: "record-1",
recipientUserId: "guardian-1",
metadata: {
medicationId: "med-1",
scheduleId: "sched-1",
reminderId: "rem-1",
url: "/medications/med-1",
},
});
const note = sendMock.mock.calls[0][0];
expect(note.category).toBeUndefined();
expect(note.payload).toEqual({ eventType: "MEDICATION_REMINDER" });
});
it("forwards reminderId on MEASUREMENT_REMINDER and strips unlisted keys", async () => {
// The iOS "Erledigt" action POSTs the reminder completion directly
// from a foreground / handled notification, so the reminder's id must
@@ -200,6 +200,26 @@ describe("sendViaTelegram — per-slot delete scope (H2)", () => {
]);
});
it("omits a record's inline actions for a managed Guardian", async () => {
await sendViaTelegram(
{ botToken: "bot-token", chatId: "guardian-chat" },
{
...reminderPayload({
replyMarkup: {
inline_keyboard: [[{ text: "Take", callback_data: "taken:med-1" }]],
},
}),
userId: "managed-record",
recordUserId: "managed-record",
recipientUserId: "guardian-1",
},
);
expect(
sendTelegramMessageMock.mock.calls[0][3].replyMarkup,
).toBeUndefined();
});
it("updates one recipient slot when its Telegram chat changes", async () => {
findUniqueMock.mockResolvedValue({ chatId: "old-chat", messageId: 123 });
@@ -146,6 +146,24 @@ describe("ntfy urgent mapping", () => {
expect(headers.Priority).toBe("5");
expect(headers.Tags).toBe("reminder");
});
it("does not send ntfy action headers to a managed Guardian", async () => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
await sendViaNtfy(
{ serverUrl: "https://ntfy.example.com", topic: "t" },
payload({
userId: "record-1",
recordUserId: "record-1",
recipientUserId: "guardian-1",
}),
);
const headers = (
safeFetchMock.mock.calls[0][1] as {
headers: Record<string, string>;
}
).headers;
expect(headers.Actions).toBeUndefined();
});
});
describe("webhook urgent mapping", () => {