diff --git a/src/__tests__/guardian-notification-payloads.test.ts b/src/__tests__/guardian-notification-payloads.test.ts new file mode 100644 index 000000000..03ad2f5b0 --- /dev/null +++ b/src/__tests__/guardian-notification-payloads.test.ts @@ -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); + }); +}); diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts index 05431bd59..ac4e3b012 100644 --- a/src/app/api/telegram/webhook/__tests__/route.test.ts +++ b/src/app/api/telegram/webhook/__tests__/route.test.ts @@ -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:' creates a MedicationIntakeEvent + clears snoozedUntil + acks the callback", async () => { const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); expect(res.status).toBe(200); diff --git a/src/lib/notifications/__tests__/web-push-dose-clear.test.ts b/src/lib/notifications/__tests__/web-push-dose-clear.test.ts index 684a85cea..c2b3d76ad 100644 --- a/src/lib/notifications/__tests__/web-push-dose-clear.test.ts +++ b/src/lib/notifications/__tests__/web-push-dose-clear.test.ts @@ -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", () => { diff --git a/src/lib/notifications/senders/__tests__/apns.test.ts b/src/lib/notifications/senders/__tests__/apns.test.ts index 1084f838d..85d1a8676 100644 --- a/src/lib/notifications/senders/__tests__/apns.test.ts +++ b/src/lib/notifications/senders/__tests__/apns.test.ts @@ -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 diff --git a/src/lib/notifications/senders/__tests__/telegram.test.ts b/src/lib/notifications/senders/__tests__/telegram.test.ts index 292900143..fdbc30d10 100644 --- a/src/lib/notifications/senders/__tests__/telegram.test.ts +++ b/src/lib/notifications/senders/__tests__/telegram.test.ts @@ -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 }); diff --git a/src/lib/notifications/senders/__tests__/urgency.test.ts b/src/lib/notifications/senders/__tests__/urgency.test.ts index bf94783ac..a66b01831 100644 --- a/src/lib/notifications/senders/__tests__/urgency.test.ts +++ b/src/lib/notifications/senders/__tests__/urgency.test.ts @@ -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; + } + ).headers; + expect(headers.Actions).toBeUndefined(); + }); }); describe("webhook urgent mapping", () => {