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

+112
View File
@@ -13103,6 +13103,7 @@ paths:
- WEB
- TELEGRAM
- DAYLIO
- NOTIFICATION
- in: query
name: from
schema:
@@ -21632,6 +21633,56 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
/api/notifications/action/{token}:
post:
tags:
- Notifications
summary: Log a quick answer tapped inside a notification
description: "Called by a notification app (ntfy `http` action), never by a signed-in client: the signed path token is
the only credential. The token is bound to one user and one answer, expires six hours after the reminder was
sent, and is single-use across all answers offered on that reminder. Rate-limited per client address before the
token is read. Writes a mood entry with source `NOTIFICATION`; refused with 403 when the mood module is off."
parameters:
- in: path
name: token
schema:
type: string
description: Signed action token from the notification's URL.
required: true
description: Signed action token from the notification's URL.
responses:
"200":
description: The answer was logged.
content:
application/json:
schema:
$ref: "#/components/schemas/NotificationActionResponse"
"401":
description: Invalid token (forged, altered, or unknown user).
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"403":
description: The mood module is turned off for this account.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"409":
description: This reminder was already answered.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"410":
description: The answer expired.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
/api/notifications/preferences:
get:
tags:
@@ -27417,6 +27468,14 @@ components:
properties:
clientManaged:
type: boolean
content:
type: object
properties:
detail:
type: string
enum:
- generic
- detailed
baseUpdatedAt:
description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the
legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored
@@ -28816,6 +28875,7 @@ components:
- WEB
- TELEGRAM
- DAYLIO
- NOTIFICATION
externalId:
description: "iOS-side source-stable id for idempotent dedup on the `(userId, source, externalId)` key. Must be stable
across app restarts — an object description or memory address (`<Class: 0x…>`, `0x12ab34cd`) is a new value
@@ -29336,6 +29396,7 @@ components:
- WEB
- TELEGRAM
- DAYLIO
- NOTIFICATION
externalId:
type: string
minLength: 1
@@ -52677,6 +52738,19 @@ components:
- nudgeRoutine
- nudgeFrequency
additionalProperties: false
content:
type: object
properties:
detail:
type: string
enum:
- generic
- detailed
description: What a notification may show. "generic" (default) names what it is about with no health figure; "detailed"
adds figures such as the morning score. Tap targets and quick answers are the same in both.
required:
- detail
additionalProperties: false
updatedAt:
description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as
`baseUpdatedAt` on the next write. Opaque — only ever echo a server-returned value, never parse or
@@ -52689,6 +52763,7 @@ components:
- mood
- cycle
- coach
- content
additionalProperties: false
PatchNotificationPrefsResponse:
type: object
@@ -65116,6 +65191,7 @@ components:
- WEB
- TELEGRAM
- DAYLIO
- NOTIFICATION
externalId:
anyOf:
- type: string
@@ -78271,6 +78347,42 @@ components:
required:
- ok
additionalProperties: false
NotificationActionResponse:
type: object
properties:
data:
$ref: "#/components/schemas/NotificationActionResult"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
NotificationActionResult:
type: object
properties:
logged:
type: boolean
const: true
action:
type: string
enum:
- mood-good
- mood-okay
- mood-bad
description: "The answer that was logged: mood-good = 4 (GUT), mood-okay = 3 (OKAY), mood-bad = 2 (SCHLECHT) on the
five-point mood scale."
required:
- logged
- action
additionalProperties: false
description: A quick answer from a notification, logged as a mood entry.
GetNotificationPreferences:
type: object
properties:
+89
View File
@@ -234,6 +234,8 @@ choice existed keeps sending:
message are masked as they are on every other channel.
- `priority` is a word: `urgent` for an event flagged urgent, `high` for
medication reminders, `default` for everything else.
- `url`, when `APP_URL` is set, is the page the notification leads to (see
[Tap targets](#what-tapping-a-notification-opens)). Absent otherwise.
**Gotify** sends the body Gotify's `POST /message` endpoint expects:
@@ -450,6 +452,93 @@ Two gotchas worth knowing before you blame a stale token:
> in the web UI. If you only run the PWA, ignore it entirely — your
> medication reminders come from the server cron over the channels above.
## Tap targets, content and quick answers
### What tapping a notification opens
Every notification leads somewhere in the app. The server decides where,
once per event type, and every channel carries the same target:
| Notification | Opens |
| -------------------------------------------- | ---------------------------------------------------------------------------------- |
| Mood reminder | `/mood`, with the mood entry sheet already open |
| Morning briefing | the dashboard |
| Medication reminder, low supply, shared dose | `/medications` (low supply opens the medication) |
| Check-up and measurement reminders | `/checkups` |
| Proactive Coach nudge | the Coach conversation it started |
| Cycle reminders | `/cycle` (nothing with discreet notifications on) |
| New sign-in | Settings, Security |
| Integration sync failing | Settings, Integrations (an admin alert about another account opens the admin page) |
| Illness red flag | the illness episode |
| Other system alerts | the dashboard, or the admin status page for operator alerts |
How each channel carries it:
- **ntfy**: the `Click` header, so tapping the notification opens the page.
- **Telegram**: an **Open in HealthLog** button under the message, below any
action buttons. Telegram only accepts `https` button links on a public
host, so a plain-HTTP instance gets a text link instead, and an instance
on `localhost` gets no link.
- **Web Push**: the service worker opens the page, or focuses an open
HealthLog tab and navigates it there.
- **APNs**: the path rides the payload as `url`.
- **Webhook**: the generic body gains a `url` field; the Gotify body gains
`extras["client::notification"].click.url`, which the Gotify app opens.
- **Email**: a link line under the message.
ntfy, Telegram, the webhook and email need an absolute link, built from
`APP_URL` (or `NEXT_PUBLIC_APP_URL`). Without one those channels deliver as
before, just without a link. A notification about a profile you look after
as a Guardian carries no deep link, because your session opens on your own
record.
### Health figures in notifications
Settings, Notifications, **Health figures in notifications** decides what a
notification may say on a lock screen:
- **Off** (the default): a notification says what it is about and shows no
health figure. The morning briefing reads "Your morning briefing".
- **On**: notifications add their figures. The morning briefing becomes
"Morning score 78" with the day's one-line summary underneath.
Tap targets and quick answers work the same either way. The setting is
stored as `content.detail` (`generic` or `detailed`) in the notification
preferences (`PATCH /api/auth/me/notification-prefs`) and travels with the
account backup.
### Quick answers on the mood reminder
On **ntfy** the mood reminder carries three buttons, **Good**, **Okay** and
**Bad**. Tapping one logs a mood entry straight from the notification,
without opening the app, and clears the notification. They map onto the
five-point mood scale as Good = 4, Okay = 3, Bad = 2; the outer points
stay in the app. The entry is marked with the source **Notification**.
Each button calls `POST <APP_URL>/api/notifications/action/<token>`. The
token is the only credential, so it is built to be harmless on its own:
- it is signed with `API_TOKEN_HMAC_KEY` and names one account and one
answer, so it cannot be moved to another account or another answer;
- it expires six hours after the reminder was sent;
- the three answers of one reminder are one use between them: the first
tap logs, every later tap on that reminder is refused;
- the route is rate-limited before it reads the token, refuses when the
mood module has been turned off, and the token is scrubbed from logs.
The buttons need `APP_URL`, because the ntfy app calls the URL from your
phone. Your phone has to reach the instance at that address.
**Telegram** keeps its own keyboard on the mood reminder: the full 1 to 5
scale, a note and a "later" button.
**Web Push** offers no buttons. Most browsers show at most two actions,
which cannot carry a three-way answer, and Safari on iPhone shows none.
Tapping the notification opens the mood entry sheet instead.
**APNs**: the iOS app draws its own buttons from the notification category
it registers for the mood reminder.
## Which channel should I pick?
| Your setup | Recommended channel |
@@ -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",
),
},
},
@@ -0,0 +1,103 @@
/**
* v1.42.2 — a mood quick answer from a notification writes exactly once.
*
* The three answers on one reminder share a nonce, and the write is keyed on
* it through `MoodEntry`'s unique `(userId, source, externalId)`. Against
* real Postgres and the real route: the first answer logs, a repeat of the
* same answer, a different answer on the same reminder, and two answers
* racing each other all leave exactly one row behind.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
const USER = "user-notification-action";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
function call(token: string) {
return import("@/app/api/notifications/action/[token]/route").then(
({ POST }) =>
POST(
new NextRequest(`http://localhost/api/notifications/action/${token}`, {
method: "POST",
}),
{ params: Promise.resolve({ token }) },
),
);
}
async function tokens(nonce: string) {
const { mintActionToken } = await import("@/lib/notifications/action-token");
return {
good: mintActionToken({ userId: USER, action: "mood-good", nonce }),
bad: mintActionToken({ userId: USER, action: "mood-bad", nonce }),
};
}
beforeEach(async () => {
vi.stubEnv(
"API_TOKEN_HMAC_KEY",
"integration-hmac-key-at-least-32-characters",
);
await truncateAllTables(getPrismaClient());
await getPrismaClient().user.create({
data: {
id: USER,
username: "notification-action",
email: "notification-action@example.test",
timezone: "Europe/Berlin",
},
});
});
describe("POST /api/notifications/action/{token}", () => {
it("logs the first answer once and refuses every later answer on the reminder", async () => {
const { good, bad } = await tokens("reminder-1");
const first = await call(good);
expect(first.status).toBe(200);
const repeat = await call(good);
expect(repeat.status).toBe(409);
const other = await call(bad);
expect(other.status).toBe(409);
const rows = await getPrismaClient().moodEntry.findMany({
where: { userId: USER },
select: { source: true, externalId: true, score: true, mood: true },
});
expect(rows).toEqual([
{
source: "NOTIFICATION",
externalId: "notification-action:reminder-1",
score: 4,
mood: "GUT",
},
]);
});
it("lets only one of two racing answers write", async () => {
const { good, bad } = await tokens("reminder-2");
const statuses = (await Promise.all([call(good), call(bad)]))
.map((res) => res.status)
.sort();
expect(statuses).toEqual([200, 409]);
expect(
await getPrismaClient().moodEntry.count({ where: { userId: USER } }),
).toBe(1);
});
it("answers a separate reminder with its own entry", async () => {
const one = await tokens("reminder-3");
const two = await tokens("reminder-4");
expect((await call(one.good)).status).toBe(200);
expect((await call(two.bad)).status).toBe(200);
expect(
await getPrismaClient().moodEntry.count({ where: { userId: USER } }),
).toBe(2);
});
});