test(notifications): cover tap targets, quick-answer tokens and the action route; document them

This commit is contained in:
Marc-André Bombeck committed 2026-10-11 12:33:02 +02:00
1 parent 40e66bf027
commit 5435890275
16 files changed
+1300 -2

No files matched your search

@@ -0,0 +1,154 @@
/**
* POST /api/notifications/action/{token} — a quick answer from a
* notification. Pins the order of the gates and every refusal class: rate
* limit before the token is read, then signature, expiry, account, module,
* and the single-use write.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const TEST_HMAC_KEY = "test-hmac-key-at-least-32-chars-long-xxxxx";
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn() } },
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(),
rateLimitHeaders: vi.fn(() => ({ "Retry-After": "60" })),
}));
vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() }));
vi.mock("@/lib/mood/create-from-telegram", () => ({
logTelegramMood: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { isModuleEnabled } from "@/lib/modules/gate";
import { logTelegramMood } from "@/lib/mood/create-from-telegram";
import {
ACTION_TOKEN_TTL_MS,
mintActionToken,
} from "@/lib/notifications/action-token";
function call(token: string) {
const req = new NextRequest(
`http://localhost/api/notifications/action/${token}`,
{ method: "POST" },
);
return POST(req, { params: Promise.resolve({ token }) });
}
function token(over: Partial<Parameters<typeof mintActionToken>[0]> = {}) {
return mintActionToken({
userId: "user-1",
action: "mood-okay",
nonce: "nonce-1",
...over,
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.stubEnv("API_TOKEN_HMAC_KEY", TEST_HMAC_KEY);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
remaining: 19,
resetAt: new Date(),
ip: "203.0.113.5",
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user-1",
timezone: "Europe/Berlin",
} as never);
vi.mocked(isModuleEnabled).mockResolvedValue(true);
vi.mocked(logTelegramMood).mockResolvedValue({
created: true,
moodEntryId: "m1",
score: 3,
});
});
describe("POST /api/notifications/action/{token}", () => {
it("logs the answer under the reminder's nonce with the notification source", async () => {
const res = await call(token());
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
data: { logged: true, action: "mood-okay" },
error: null,
});
expect(logTelegramMood).toHaveBeenCalledWith({
userId: "user-1",
score: 3,
tz: "Europe/Berlin",
externalId: "notification-action:nonce-1",
source: "NOTIFICATION",
});
});
it("is rate-limited before the token is read", async () => {
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: new Date(),
ip: "203.0.113.5",
} as never);
const res = await call(token());
expect(res.status).toBe(429);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
expect(logTelegramMood).not.toHaveBeenCalled();
});
it("refuses a tampered token with 401 and writes nothing", async () => {
const forged = token().replace("user-1", "user-2");
const res = await call(forged);
expect(res.status).toBe(401);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
expect(logTelegramMood).not.toHaveBeenCalled();
});
it("refuses an expired token with 410", async () => {
const res = await call(
token({ now: Date.now() - ACTION_TOKEN_TTL_MS - 1 }),
);
expect(res.status).toBe(410);
expect(logTelegramMood).not.toHaveBeenCalled();
});
it("refuses a token for an account that no longer exists", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
const res = await call(token());
expect(res.status).toBe(401);
expect(logTelegramMood).not.toHaveBeenCalled();
});
it("writes only for the user the token names", async () => {
await call(token({ userId: "user-9" }));
expect(prisma.user.findUnique).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: "user-9" } }),
);
});
it("refuses with 403 when the mood module was switched off", async () => {
vi.mocked(isModuleEnabled).mockResolvedValue(false);
const res = await call(token());
expect(res.status).toBe(403);
expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "mood");
expect(logTelegramMood).not.toHaveBeenCalled();
});
it("refuses a reused reminder with 409", async () => {
vi.mocked(logTelegramMood).mockResolvedValue({
created: false,
moodEntryId: "m1",
score: 3,
});
const res = await call(token({ action: "mood-bad" }));
expect(res.status).toBe(409);
});
});
@@ -123,6 +123,69 @@ describe("buildDailyBriefingPush", () => {
});
});
describe("buildDailyBriefingPush — content detail", () => {
const { t } = getServerTranslator("en");
it("generic (the default) keeps the score off the lock screen", () => {
const { title, body } = buildDailyBriefingPush(makeDigest(), t);
expect(title).toBe("Your morning briefing");
expect(`${title} ${body}`).not.toMatch(/\d/);
});
it("detailed puts the score in the title and keeps the line as the label", () => {
const { title, body } = buildDailyBriefingPush(makeDigest(), t, {
showFigures: true,
});
expect(title).toBe("Morning score 82");
expect(body).toBe("Sleep looked solid last night.");
});
it("detailed swaps the 'score is ready' floor for the band label", () => {
const digest = makeDigest({
briefingLead: null,
score: { value: 64.6, band: "yellow", delta: null },
line: t("daily.line.scoreReady"),
});
const { title, body } = buildDailyBriefingPush(digest, t, {
showFigures: true,
});
expect(title).toBe("Morning score 65");
expect(body).toBe(t("daily.push.band.yellow"));
});
it("detailed without a score falls back to the plain title", () => {
const { title } = buildDailyBriefingPush(makeDigest({ score: null }), t, {
showFigures: true,
});
expect(title).toBe("Your morning briefing");
});
it("reads the choice from the user's notification preferences", async () => {
const detailed = makeDeps();
await maybeDispatchDailyBriefing(
makePrisma({
user: {
id: "u1",
timezone: "Europe/Berlin",
locale: "en",
morningDigestRefreshedOn: null,
notificationPrefs: { content: { detail: "detailed" } },
},
}),
"u1",
IN_WINDOW,
detailed,
);
expect(detailed.dispatch.mock.calls[0][0].title).toBe("Morning score 82");
const generic = makeDeps();
await maybeDispatchDailyBriefing(makePrisma({}), "u1", IN_WINDOW, generic);
expect(generic.dispatch.mock.calls[0][0].title).toBe(
"Your morning briefing",
);
});
});
describe("digestHasSubstance", () => {
it("is false for an empty account (score/lead/signal all null, no items)", () => {
expect(
@@ -0,0 +1,168 @@
/**
* Notification quick-action tokens: what a URL holding one can and cannot do.
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
ACTION_TOKEN_TTL_MS,
MOOD_QUICK_ACTIONS,
QUICK_ACTION_IDS,
buildMoodQuickActions,
mintActionToken,
quickActionExternalId,
verifyActionToken,
} from "@/lib/notifications/action-token";
const ORIGINAL_KEY = process.env.API_TOKEN_HMAC_KEY;
const NOW = Date.UTC(2026, 9, 11, 20, 0, 0);
beforeEach(() => {
process.env.API_TOKEN_HMAC_KEY = "k".repeat(64);
});
afterEach(() => {
if (ORIGINAL_KEY === undefined) delete process.env.API_TOKEN_HMAC_KEY;
else process.env.API_TOKEN_HMAC_KEY = ORIGINAL_KEY;
});
function mint(over: Partial<Parameters<typeof mintActionToken>[0]> = {}) {
return mintActionToken({
userId: "user_a",
action: "mood-good",
nonce: "nonce1",
now: NOW,
...over,
});
}
/** Replace one dot-separated field, keeping the original signature. */
function withField(token: string, index: number, value: string): string {
const parts = token.split(".");
parts[index] = value;
return parts.join(".");
}
describe("mood quick actions", () => {
it("maps good / okay / bad onto the five-point scale (4 / 3 / 2)", () => {
expect(MOOD_QUICK_ACTIONS).toEqual({
"mood-good": 4,
"mood-okay": 3,
"mood-bad": 2,
});
expect(QUICK_ACTION_IDS).toHaveLength(3);
});
it("builds three path-safe URLs sharing one nonce", () => {
const actions = buildMoodQuickActions({
userId: "user_a",
origin: "https://health.example",
labels: { "mood-good": "Good", "mood-okay": "Okay", "mood-bad": "Bad" },
now: NOW,
});
expect(actions.map((a) => a.id)).toEqual([
"mood-good",
"mood-okay",
"mood-bad",
]);
const nonces = new Set<string>();
for (const action of actions) {
expect(action.url).toMatch(
/^https:\/\/health\.example\/api\/notifications\/action\/[A-Za-z0-9._-]+$/,
);
const token = action.url.split("/").at(-1) ?? "";
const verdict = verifyActionToken(token, NOW);
expect(verdict.ok).toBe(true);
if (verdict.ok) {
expect(verdict.action).toBe(action.id);
nonces.add(verdict.nonce);
}
}
// One nonce: whichever answer lands first uses up the other two.
expect(nonces.size).toBe(1);
});
it("keys the single-use write on the nonce", () => {
expect(quickActionExternalId("abc")).toBe("notification-action:abc");
});
});
describe("verifyActionToken", () => {
it("accepts a fresh token and returns what it is bound to", () => {
const verdict = verifyActionToken(mint(), NOW + 1000);
expect(verdict).toEqual({
ok: true,
userId: "user_a",
action: "mood-good",
nonce: "nonce1",
expiry: NOW + ACTION_TOKEN_TTL_MS,
});
});
it("refuses a token at and after its expiry", () => {
const token = mint();
expect(verifyActionToken(token, NOW + ACTION_TOKEN_TTL_MS - 1).ok).toBe(
true,
);
expect(verifyActionToken(token, NOW + ACTION_TOKEN_TTL_MS)).toEqual({
ok: false,
reason: "expired",
});
});
it("refuses a token moved to another user", () => {
expect(verifyActionToken(withField(mint(), 1, "user_b"), NOW)).toEqual({
ok: false,
reason: "bad_signature",
});
});
it("refuses a token switched to another answer", () => {
expect(verifyActionToken(withField(mint(), 2, "mood-bad"), NOW)).toEqual({
ok: false,
reason: "bad_signature",
});
});
it("refuses an extended expiry as a forgery, not as fresh", () => {
const later = String(NOW + 10 * ACTION_TOKEN_TTL_MS);
expect(
verifyActionToken(
withField(mint(), 3, later),
NOW + 2 * ACTION_TOKEN_TTL_MS,
),
).toEqual({ ok: false, reason: "bad_signature" });
});
it("refuses a tampered signature and a token signed under another key", () => {
const token = mint();
const tag = token.split(".").at(-1) ?? "";
const flipped = `${tag.slice(0, -1)}${tag.endsWith("A") ? "B" : "A"}`;
expect(verifyActionToken(withField(token, 5, flipped), NOW).ok).toBe(false);
process.env.API_TOKEN_HMAC_KEY = "z".repeat(64);
expect(verifyActionToken(token, NOW)).toEqual({
ok: false,
reason: "bad_signature",
});
});
it.each([
[""],
["na1.user.mood-good.1.n"],
["na2.user_a.mood-good.1.n.tag"],
["na1.user_a.mood-great.1.n.tag"],
["na1.user_a.mood-good.01.n.tag"],
["na1..mood-good.1.n.tag"],
["x".repeat(400)],
])("refuses the malformed token %j", (token) => {
expect(verifyActionToken(token, NOW)).toEqual({
ok: false,
reason: "malformed",
});
});
it("refuses to mint without a signing key", () => {
delete process.env.API_TOKEN_HMAC_KEY;
expect(() => mint()).toThrow(/API_TOKEN_HMAC_KEY/);
});
});
@@ -0,0 +1,115 @@
/**
* The dispatcher's one place for tap targets and quick actions.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { userFindUnique } = vi.hoisted(() => ({ userFindUnique: vi.fn() }));
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: userFindUnique } },
}));
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({ addWarning: vi.fn() }),
}));
import { withDeliveryLinks } from "@/lib/notifications/delivery-links";
import { verifyActionToken } from "@/lib/notifications/action-token";
import type { NotificationPayload } from "@/lib/notifications/types";
const ORIGINAL = {
APP_URL: process.env.APP_URL,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
API_TOKEN_HMAC_KEY: process.env.API_TOKEN_HMAC_KEY,
};
beforeEach(() => {
process.env.APP_URL = "https://health.example";
delete process.env.NEXT_PUBLIC_APP_URL;
process.env.API_TOKEN_HMAC_KEY = "k".repeat(64);
userFindUnique.mockResolvedValue({ locale: "de" });
});
afterEach(() => {
vi.clearAllMocks();
for (const [key, value] of Object.entries(ORIGINAL)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
function payload(over: Partial<NotificationPayload> = {}): NotificationPayload {
return {
eventType: "MOOD_REMINDER",
userId: "u1",
recordUserId: "u1",
recipientUserId: "u1",
title: "t",
message: "m",
...over,
};
}
describe("withDeliveryLinks", () => {
it("attaches the tap target in the recipient's language", async () => {
const out = await withDeliveryLinks(
payload({ eventType: "DAILY_BRIEFING" }),
"u1",
);
expect(out.tap).toEqual({
path: "/",
url: "https://health.example/",
label: "In HealthLog öffnen",
});
expect(out.quickActions).toBeUndefined();
});
it("gives a self-delivered mood reminder three signed answers for its recipient", async () => {
const now = Date.UTC(2026, 9, 11, 20);
const out = await withDeliveryLinks(payload(), "u1", now);
expect(out.tap?.url).toBe("https://health.example/mood?add=1");
expect(out.quickActions?.map((a) => [a.id, a.label])).toEqual([
["mood-good", "Gut"],
["mood-okay", "Okay"],
["mood-bad", "Schlecht"],
]);
for (const action of out.quickActions ?? []) {
const verdict = verifyActionToken(action.url.split("/").at(-1), now);
expect(verdict.ok && verdict.userId).toBe("u1");
}
});
it("offers no answers to a Guardian", async () => {
const out = await withDeliveryLinks(
payload({ recordUserId: "record-1", recipientUserId: "guardian-1" }),
"guardian-1",
);
expect(out.quickActions).toBeUndefined();
expect(out.tap).toBeUndefined();
});
it("keeps the path but drops absolute URLs and answers without a public origin", async () => {
delete process.env.APP_URL;
const out = await withDeliveryLinks(payload(), "u1");
expect(out.tap).toEqual({
path: "/mood?add=1",
url: null,
label: "In HealthLog öffnen",
});
expect(out.quickActions).toBeUndefined();
});
it("still delivers, without answers, when no signing key is configured", async () => {
delete process.env.API_TOKEN_HMAC_KEY;
const out = await withDeliveryLinks(payload(), "u1");
expect(out.quickActions).toBeUndefined();
expect(out.tap?.path).toBe("/mood?add=1");
});
it("returns the silent sync push untouched, without a locale read", async () => {
const input = payload({ eventType: "MEDICATION_INTAKE_SYNC" });
const out = await withDeliveryLinks(input, "u1");
expect(out).toBe(input);
expect(userFindUnique).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,166 @@
/**
* The event → tap-target map and its resolver.
*
* Walks `EVENT_TYPES` rather than the map's own keys, so an event type added
* to the union without a decision about where it leads fails here (the
* `Record<EventType, …>` type already refuses to compile without one; this
* pins that every entry is also a usable path).
*/
import { afterEach, describe, expect, it } from "vitest";
import { EVENT_TYPES, type EventType } from "@/lib/notifications/types";
import {
NOTIFICATION_TAP_PATHS,
absoluteAppUrl,
configuredAppOrigin,
isSafeAppPath,
resolveTapPath,
telegramLinkMode,
} from "@/lib/notifications/tap-target";
/** Events never shown to the user, so they carry no target. */
const SILENT: ReadonlySet<EventType> = new Set(["MEDICATION_INTAKE_SYNC"]);
const ORIGINAL = {
APP_URL: process.env.APP_URL,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
};
afterEach(() => {
for (const [key, value] of Object.entries(ORIGINAL)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
describe("NOTIFICATION_TAP_PATHS", () => {
it("names a target for every event type", () => {
expect(EVENT_TYPES.length).toBeGreaterThan(0);
for (const eventType of EVENT_TYPES) {
expect(
Object.prototype.hasOwnProperty.call(NOTIFICATION_TAP_PATHS, eventType),
`${eventType} has no tap target`,
).toBe(true);
const path = NOTIFICATION_TAP_PATHS[eventType];
if (SILENT.has(eventType)) {
expect(path, `${eventType} is silent`).toBeNull();
} else {
expect(isSafeAppPath(path), `${eventType} → ${path}`).toBe(true);
expect(resolveTapPath({ eventType }), eventType).toBe(path);
}
}
});
it("keeps the targets the issue names", () => {
expect(NOTIFICATION_TAP_PATHS.MOOD_REMINDER).toBe("/mood?add=1");
expect(NOTIFICATION_TAP_PATHS.DAILY_BRIEFING).toBe("/");
expect(NOTIFICATION_TAP_PATHS.WITHINGS_SYNC_FAILED).toBe(
"/settings/integrations",
);
expect(NOTIFICATION_TAP_PATHS.MEDICATION_REMINDER).toBe("/medications");
});
it("has no entry for an event type outside the union", () => {
expect(Object.keys(NOTIFICATION_TAP_PATHS).sort()).toEqual(
[...EVENT_TYPES].sort(),
);
});
});
describe("resolveTapPath", () => {
it("takes a producer's same-origin path over the default", () => {
expect(
resolveTapPath({
eventType: "MEDICATION_LOW_STOCK",
metadata: { url: "/medications/m1?tab=bestand" },
}),
).toBe("/medications/m1?tab=bestand");
});
it.each([
"https://evil.example/",
"//evil.example/x",
"/\\evil.example",
"javascript:alert(1)",
"/a b",
"",
42,
])("ignores an unsafe override %j", (url) => {
expect(
resolveTapPath({ eventType: "COACH_NUDGE", metadata: { url } }),
).toBe("/coach");
});
it("gives a managed Guardian delivery no path", () => {
expect(
resolveTapPath({
eventType: "MEDICATION_REMINDER",
recordUserId: "record-1",
recipientUserId: "guardian-1",
}),
).toBeNull();
});
it("keeps a self delivery's path when both ids are the same", () => {
expect(
resolveTapPath({
eventType: "MEDICATION_REMINDER",
recordUserId: "u1",
recipientUserId: "u1",
}),
).toBe("/medications");
});
it("gives a discreet delivery no path, so nothing names the cycle surface", () => {
expect(
resolveTapPath({ eventType: "CYCLE_PERIOD_SOON", discreet: true }),
).toBeNull();
});
it("gives the silent sync push no path even with an override", () => {
expect(
resolveTapPath({
eventType: "MEDICATION_INTAKE_SYNC",
metadata: { url: "/medications" },
}),
).toBeNull();
});
});
describe("configuredAppOrigin / absoluteAppUrl", () => {
it("prefers APP_URL, then NEXT_PUBLIC_APP_URL, and keeps only the origin", () => {
process.env.APP_URL = "https://health.example/some/path";
process.env.NEXT_PUBLIC_APP_URL = "https://other.example";
expect(configuredAppOrigin()).toBe("https://health.example");
delete process.env.APP_URL;
expect(configuredAppOrigin()).toBe("https://other.example");
});
it("returns null without a usable origin, and so no absolute URL", () => {
delete process.env.APP_URL;
process.env.NEXT_PUBLIC_APP_URL = "not a url";
expect(configuredAppOrigin()).toBeNull();
expect(absoluteAppUrl("/mood?add=1")).toBeNull();
});
it("joins origin and path", () => {
expect(absoluteAppUrl("/mood?add=1", "https://h.example")).toBe(
"https://h.example/mood?add=1",
);
expect(absoluteAppUrl(null, "https://h.example")).toBeNull();
});
});
describe("telegramLinkMode", () => {
it.each([
["https://health.example/", "button"],
["http://nas.lan:3000/", "text"],
["https://localhost:3000/", "none"],
["http://127.0.0.1/", "none"],
["https://app.localhost/", "none"],
["ftp://health.example/", "none"],
["nonsense", "none"],
])("%s → %s", (url, mode) => {
expect(telegramLinkMode(url)).toBe(mode);
});
});
@@ -190,6 +190,39 @@ describe("web-push reminder push — stable tag + badge", () => {
});
});
describe("web-push tap target", () => {
async function sentBody(payload: Parameters<typeof sendViaWebPush>[1]) {
findManyMock.mockResolvedValue([SUB]);
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(null, { status: 201 }),
);
await sendViaWebPush("user-1", payload);
return JSON.parse(generateRequestDetailsMock.mock.calls[0][1] as string);
}
it("carries the dispatcher's resolved tap path as data url", async () => {
const body = await sentBody({
eventType: "MOOD_REMINDER",
userId: "user-1",
title: "Log your mood",
message: "How are you feeling today?",
tap: { path: "/mood?add=1", url: null, label: "Open" },
});
expect(body.url).toBe("/mood?add=1");
});
it("refuses a protocol-relative metadata url when no tap was resolved", async () => {
const body = await sentBody({
eventType: "SYSTEM_ALERT",
userId: "user-1",
title: "t",
message: "m",
metadata: { url: "//evil.example/x" },
});
expect(body.url).toBe("/");
});
});
describe("clear-on-taken push", () => {
it("emits type:clear with the SAME slot tag as the reminder + badge", async () => {
findManyMock.mockResolvedValue([SUB]);
@@ -698,6 +698,52 @@ describe("sendViaApns — dispatcher fan-out", () => {
expect(note.payload).toEqual({ eventType: "MEDICATION_REMINDER" });
});
it("carries the resolved tap path as the `url` payload key", 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("u-1", {
title: "t",
message: "m",
eventType: "MOOD_REMINDER",
metadata: { localDate: "2026-10-11" },
tap: {
path: "/mood?add=1",
url: "https://health.example/mood?add=1",
label: "Open",
},
});
const note = sendMock.mock.calls[0][0];
expect(note.payload).toEqual({
eventType: "MOOD_REMINDER",
localDate: "2026-10-11",
url: "/mood?add=1",
});
// Unchanged keys: the category still names the event.
expect(note.category).toBe("MOOD_REMINDER");
});
it("never gives a managed Guardian a tap path, even when one is set", 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",
recordUserId: "record-1",
recipientUserId: "guardian-1",
tap: { path: "/medications", url: null, label: "Open" },
});
expect(sendMock.mock.calls[0][0].payload.url).toBeUndefined();
});
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
@@ -277,3 +277,25 @@ describe("sendViaEmail — transport and envelope faults are named too", () => {
expect(result.failureCode).toBe(failureCode);
});
});
describe("sendViaEmail — tap target", () => {
it("appends the tap link as a plain-text line", async () => {
loadEmailConfigMock.mockReturnValue(transport);
sendMailMock.mockResolvedValue({ messageId: "1" });
await sendViaEmail(
{ recipient: "you@example.com" },
payload({
tap: {
path: "/settings/security",
url: "https://health.example/settings/security",
label: "Open in HealthLog",
},
}),
);
expect(sendMailMock.mock.calls[0][0].text).toBe(
"Body\n\nOpen in HealthLog: https://health.example/settings/security",
);
});
});
@@ -268,3 +268,88 @@ describe("sendViaNtfy — what the server said (#947)", () => {
expect((await sendViaNtfy(config, payload())).failureCode).toBe("timeout");
});
});
describe("sendViaNtfy — tap target and quick actions", () => {
function headersOfFirstCall(): Record<string, string> {
const [, init] = safeFetchMock.mock.calls[0] as [
string,
{ headers: Record<string, string> },
];
return init.headers;
}
it("sets Click to the resolved absolute tap URL", async () => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
await sendViaNtfy(
config,
payload({
eventType: "DAILY_BRIEFING",
tap: {
path: "/",
url: "https://health.example/",
label: "Open in HealthLog",
},
}),
);
expect(headersOfFirstCall().Click).toBe("https://health.example/");
expect(headersOfFirstCall().Actions).toBeUndefined();
});
it("omits Click when no public origin produced a URL", async () => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
await sendViaNtfy(
config,
payload({ tap: { path: "/", url: null, label: "Open" } }),
);
expect(headersOfFirstCall().Click).toBeUndefined();
});
it("sends the quick answers as three POST http actions that clear the notification", async () => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
await sendViaNtfy(
config,
payload({
eventType: "MOOD_REMINDER",
quickActions: [
{ id: "mood-good", label: "Good", url: "https://h.example/a/1" },
{ id: "mood-okay", label: "Okay", url: "https://h.example/a/2" },
{ id: "mood-bad", label: "Bad", url: "https://h.example/a/3" },
],
}),
);
expect(headersOfFirstCall().Actions).toBe(
"http, Good, https://h.example/a/1, method=POST, clear=true; " +
"http, Okay, https://h.example/a/2, method=POST, clear=true; " +
"http, Bad, https://h.example/a/3, method=POST, clear=true",
);
});
});
describe("ntfy header helpers", () => {
it("caps the actions at three and strips separators from labels", async () => {
const { buildNtfyActionsHeader } = await import("../ntfy");
const header = buildNtfyActionsHeader({
quickActions: [
{ id: "mood-good", label: 'Go;od, "yes"', url: "https://h/1" },
{ id: "mood-okay", label: "B", url: "https://h/2" },
{ id: "mood-bad", label: "C", url: "https://h/3" },
{ id: "mood-bad", label: "D", url: "https://h/4" },
],
});
expect(header?.split("; ")).toHaveLength(3);
expect(header).toContain("http, Go od yes, https://h/1");
expect(header).not.toContain("https://h/4");
expect(buildNtfyActionsHeader({ quickActions: [] })).toBeNull();
});
it("RFC 2047-encodes a non-ASCII header value and leaves ASCII alone", async () => {
const { ntfyHeaderValue } = await import("../ntfy");
expect(ntfyHeaderValue("Morning score 78")).toBe("Morning score 78");
const encoded = ntfyHeaderValue("아침 점수 78");
expect(encoded).toMatch(/^=\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=$/);
const inner = encoded.slice("=?UTF-8?B?".length, -2);
expect(Buffer.from(inner, "base64").toString("utf8")).toBe("아침 점수 78");
// `ü` is Latin-1, which fetch would send as one invalid UTF-8 byte.
expect(ntfyHeaderValue("Morgenüberblick")).toMatch(/^=\?UTF-8\?B\?/);
});
});
@@ -347,3 +347,117 @@ describe("sendViaTelegram — interactive mood + measurement (v1.19.0)", () => {
expect(scheduleTelegramAutoDeleteMock).not.toHaveBeenCalled();
});
});
describe("sendViaTelegram — tap target", () => {
beforeEach(() => {
sendTelegramMessageMock.mockResolvedValue({ ok: true, messageId: 5 });
findUniqueMock.mockResolvedValue(null);
upsertMock.mockResolvedValue(undefined);
userFindUniqueMock.mockResolvedValue({ locale: "en" });
scheduleTelegramAutoDeleteMock.mockResolvedValue(undefined);
});
afterEach(() => {
vi.clearAllMocks();
});
function sent(): {
text: string;
replyMarkup?: { inline_keyboard: Record<string, string>[][] };
} {
const call = sendTelegramMessageMock.mock.calls[0] as [
string,
string,
string,
{ replyMarkup?: { inline_keyboard: Record<string, string>[][] } },
];
return { text: call[2], replyMarkup: call[3].replyMarkup };
}
it("adds an https URL button as its own row under the action buttons", async () => {
await sendViaTelegram(config, {
...reminderPayload(),
tap: {
path: "/medications",
url: "https://health.example/medications",
label: "Open in HealthLog",
},
});
const rows = sent().replyMarkup?.inline_keyboard ?? [];
// The medication keyboard keeps its two callback rows first.
expect(rows[0]?.[0]?.callback_data).toBe("taken:med-1");
expect(rows.at(-1)).toEqual([
{
text: "Open in HealthLog",
url: "https://health.example/medications",
},
]);
expect(sent().text).toBe("m");
});
it("creates a keyboard with only the URL button for an event without actions", async () => {
await sendViaTelegram(config, {
eventType: "DAILY_BRIEFING",
userId: "user-1",
title: "t",
message: "line",
tap: { path: "/", url: "https://health.example/", label: "Open" },
});
expect(sent().replyMarkup).toEqual({
inline_keyboard: [[{ text: "Open", url: "https://health.example/" }]],
});
});
it("falls back to an escaped text link on a plain-HTTP origin Telegram refuses as a button", async () => {
await sendViaTelegram(config, {
eventType: "DAILY_BRIEFING",
userId: "user-1",
title: "t",
message: "line",
tap: {
path: "/?a=1&b=2",
url: "http://nas.lan/?a=1&b=2",
label: "Open <app>",
},
});
expect(sent().replyMarkup).toBeUndefined();
expect(sent().text).toBe(
'line\n\n<a href="http://nas.lan/?a=1&amp;b=2">Open &lt;app&gt;</a>',
);
});
it("carries no link at all for a loopback origin", async () => {
await sendViaTelegram(config, {
eventType: "DAILY_BRIEFING",
userId: "user-1",
title: "t",
message: "line",
tap: { path: "/", url: "https://localhost:3000/", label: "Open" },
});
expect(sent().replyMarkup).toBeUndefined();
expect(sent().text).toBe("line");
});
it("keeps the 1–5 mood keyboard and adds the link row to the mood reminder", async () => {
await sendViaTelegram(config, {
eventType: "MOOD_REMINDER",
userId: "user-1",
title: "t",
message: "How are you?",
tap: {
path: "/mood?add=1",
url: "https://health.example/mood?add=1",
label: "Open",
},
});
const rows = sent().replyMarkup?.inline_keyboard ?? [];
expect(rows[0]?.map((b) => b.callback_data)).toEqual([
"mood:1",
"mood:2",
"mood:3",
"mood:4",
"mood:5",
]);
expect(rows.at(-1)?.[0]?.url).toBe("https://health.example/mood?add=1");
});
});
@@ -566,3 +566,32 @@ describe("sendViaWebhook — Gotify format (#947)", () => {
expect(JSON.parse(sentBody()).priority).toBe("default");
});
});
describe("buildWebhookBody — tap target", () => {
const tap = {
path: "/checkups",
url: "https://health.example/checkups",
label: "Open in HealthLog",
};
it("adds the absolute tap URL to the generic body", () => {
const body = JSON.parse(buildWebhookBody({}, payload({ tap })));
expect(body.url).toBe("https://health.example/checkups");
});
it("leaves the generic body without `url` when no public origin is set", () => {
const body = JSON.parse(
buildWebhookBody({}, payload({ tap: { ...tap, url: null } })),
);
expect(body).not.toHaveProperty("url");
});
it("gives Gotify a click URL in its notification extras", () => {
const body = JSON.parse(
buildWebhookBody({ format: "gotify" }, payload({ tap })),
);
expect(body.extras["client::notification"]).toEqual({
click: { url: "https://health.example/checkups" },
});
});
});
-1
View File
@@ -63,7 +63,6 @@ export function isSafeAppPath(value: unknown): value is string {
if (!value.startsWith("/")) return false;
if (value.startsWith("//")) return false;
if (value.includes("\\")) return false;
// eslint-disable-next-line no-control-regex
if (/[\s\u0000-\u001f\u007f]/.test(value)) return false;
return true;
}
+1 -1
View File
@@ -188,7 +188,7 @@ export const notificationChannelPaths: NonNullable<ZodOpenApiObject["paths"]> =
"application/json": {
schema: dataEnvelope(
notificationActionResult,
"NotificationActionResult",
"NotificationActionResponse",
),
},
},