mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
test(notifications): cover tap targets, quick-answer tokens and the action route; document them
This commit is contained in:
1 parent
40e66bf027
commit
5435890275
16 files changed
+1300
-2
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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&b=2">Open <app></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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user