mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +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
@@ -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",
|
||||
),
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user