From 255746d9f5df6ab80c388a8cbb48f32b5737616f Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Sun, 10 May 2026 23:04:24 +0200 Subject: [PATCH] feat: restore ntfy interactive settings test delivery support Squash merge PR #591: feat: restore ntfy interactive settings test delivery support --- .env.example | 8 +- backend/src/services/settings-service.ts | 5 +- .../test/notification-action-renderer.test.ts | 186 +++++++++++++++ .../test/notification-actions-service.test.ts | 225 ++++++++++++++++++ 4 files changed, 421 insertions(+), 3 deletions(-) create mode 100644 backend/src/test/notification-action-renderer.test.ts create mode 100644 backend/src/test/notification-actions-service.test.ts diff --git a/.env.example b/.env.example index 68c8b4d..b71a721 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,12 @@ PORT=3000 CORS_ORIGINS=http://localhost:4174 LOG_LEVEL=warn +# Public base URL used for notification action links. +# Required for intake reminder action buttons/links. +# PUBLIC_APP_URL=https://medassist.example.com +# For mobile testing on the same LAN, use your laptop IP instead of localhost, +# e.g. PUBLIC_APP_URL=http://192.168.0.113:5173 and add that origin to CORS_ORIGINS. + # Levels: debug, info, warn, error, silent # Controls: backend Fastify logging, frontend nginx access logs (Docker), # and frontend browser console (via build-time injection) @@ -149,6 +155,6 @@ EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning # UI defaults # DEFAULT_LANGUAGE=en # en or de # DEFAULT_STOCK_CALCULATION_MODE=automatic # automatic or manual -# DEFAULT_SHARE_STOCK_STATUS=true # Show stock status on shared schedule links +# DEFAULT_SHARE_MEDICATION_OVERVIEW=false # Show medication overview section on shared schedule links # DEFAULT_UPCOMING_TODAY_ONLY=false # DEFAULT_SHARE_SCHEDULE_TODAY_ONLY=false \ No newline at end of file diff --git a/backend/src/services/settings-service.ts b/backend/src/services/settings-service.ts index ed040e6..fcf7951 100644 --- a/backend/src/services/settings-service.ts +++ b/backend/src/services/settings-service.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { db } from "../db/client.js"; import { userSettings } from "../db/schema.js"; import type { Language } from "../i18n/translations.js"; +import { isNtfyNotificationUrl } from "./notifications/action-renderer.js"; export type UserSettings = { userId: number; @@ -81,7 +82,7 @@ export function getNotificationProvider(url: string): string { if (url.startsWith("telegram://")) return "telegram"; if (url.startsWith("gotify://")) return "gotify"; if (url.startsWith("pushover://")) return "pushover"; - if (url.startsWith("ntfy://")) return "ntfy"; + if (isNtfyNotificationUrl(url)) return "ntfy"; try { const parsed = new URL(url); @@ -231,7 +232,7 @@ export function sanitizeNotificationUrl( return { url: discordWebhookUrl, isNtfy: false }; } - const isNtfy = urlStr.startsWith("ntfy://"); + const isNtfy = isNtfyNotificationUrl(urlStr); const normalizedUrl = isNtfy ? urlStr.replace("ntfy://", "https://") : urlStr; const parsed = new URL(normalizedUrl); diff --git a/backend/src/test/notification-action-renderer.test.ts b/backend/src/test/notification-action-renderer.test.ts new file mode 100644 index 0000000..14dbaac --- /dev/null +++ b/backend/src/test/notification-action-renderer.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from "vitest"; +import { + getNotificationActionLabels, + isNtfyNotificationUrl, + type PushNotificationAction, + renderNotificationActionPayload, +} from "../services/notifications/action-renderer.js"; + +function decodeRfc2047Base64(value: string): string { + const match = /^=\?UTF-8\?B\?(.+)\?=$/.exec(value); + if (!match) { + return value; + } + + return Buffer.from(match[1], "base64").toString("utf-8"); +} + +const actions: PushNotificationAction[] = [ + { + kind: "taken", + label: "Take", + url: "https://app.example.com/api/notification-actions/taken-token", + method: "POST", + }, + { + kind: "skip", + label: "Skip", + url: "https://app.example.com/api/notification-actions/skip-token", + method: "POST", + }, + { kind: "view", label: "View", url: "https://app.example.com/?date=2026-01-05", method: "GET" }, +]; + +describe("notification action renderer", () => { + it("builds ntfy native actions without duplicate click headers", () => { + const result = renderNotificationActionPayload("ntfy://ntfy.sh/medassist", "Body", { + actions, + clickUrl: "https://app.example.com/api/notification-actions/respond-token", + respondUrl: "https://app.example.com/api/notification-actions/respond-token", + viewUrl: "https://app.example.com/?date=2026-01-05", + tags: ["pill"], + priority: 4, + sequenceId: "medassist-sequence", + }); + + expect(result.message).toBe("Body"); + expect(result.headers).toMatchObject({ + Tags: "pill", + Priority: "4", + "X-Sequence-ID": "medassist-sequence", + }); + expect(result.headers.Click).toBeUndefined(); + + const parsedActions = JSON.parse(result.headers.Actions ?? "[]"); + expect(parsedActions).toEqual([ + { + action: "http", + label: "Take", + url: "https://app.example.com/api/notification-actions/taken-token", + method: "POST", + clear: true, + }, + { + action: "http", + label: "Skip", + url: "https://app.example.com/api/notification-actions/skip-token", + method: "POST", + clear: true, + }, + { + action: "view", + label: "View", + url: "https://app.example.com/?date=2026-01-05", + clear: false, + }, + ]); + }); + + it("keeps the ntfy click header when there are no native actions", () => { + const result = renderNotificationActionPayload("ntfy://ntfy.sh/medassist", "Body", { + clickUrl: "https://app.example.com/api/notification-actions/respond-token", + }); + + expect(result.headers.Click).toBe("https://app.example.com/api/notification-actions/respond-token"); + }); + + it("treats direct https ntfy URLs as ntfy targets with native actions", () => { + const result = renderNotificationActionPayload("https://ntfy.danielvolz.org/medis_test", "Body", { + actions, + clickUrl: "https://app.example.com/api/notification-actions/respond-token", + respondUrl: "https://app.example.com/api/notification-actions/respond-token", + viewUrl: "https://app.example.com/?date=2026-01-05", + }); + + expect(isNtfyNotificationUrl("https://ntfy.danielvolz.org/medis_test")).toBe(true); + expect(result.message).toBe("Body"); + expect(result.headers.Actions).toBeTruthy(); + expect(result.message).not.toContain("Respond:"); + }); + + it("keeps insecure http mutation targets as direct ntfy http actions without the dev fallback", () => { + const result = renderNotificationActionPayload("https://ntfy.danielvolz.org/medis_test", "Body", { + actions: [ + { + kind: "taken", + label: "Take", + url: "http://192.168.0.113:5173/api/notification-actions/taken-token", + method: "POST", + }, + ], + }); + + expect(JSON.parse(result.headers.Actions ?? "[]")).toEqual([ + { + action: "http", + label: "Take", + url: "http://192.168.0.113:5173/api/notification-actions/taken-token", + method: "POST", + clear: true, + }, + ]); + }); + + it("encodes non-ascii ntfy action labels as RFC 2047 headers", () => { + const result = renderNotificationActionPayload("https://ntfy.danielvolz.org/medis_test", "Body", { + actions: [ + { + kind: "skip", + label: "Überspringen", + url: "https://app.example.com/api/notification-actions/skip-token", + method: "POST", + }, + { + kind: "view", + label: "Öffnen", + url: "https://app.example.com/?date=2026-01-05", + method: "GET", + }, + ], + }); + + expect(result.headers.Actions).toMatch(/^=\?UTF-8\?B\?/); + expect(JSON.parse(decodeRfc2047Base64(result.headers.Actions ?? "[]"))).toEqual([ + { + action: "http", + label: "Überspringen", + url: "https://app.example.com/api/notification-actions/skip-token", + method: "POST", + clear: true, + }, + { + action: "view", + label: "Öffnen", + url: "https://app.example.com/?date=2026-01-05", + clear: false, + }, + ]); + }); + + it("uses consistent action-form labels for English and German", () => { + expect(getNotificationActionLabels("en")).toEqual({ + taken: "Take", + skip: "Skip", + respond: "Respond", + view: "View", + }); + expect(getNotificationActionLabels("de")).toEqual({ + taken: "Einnehmen", + skip: "Überspringen", + respond: "Antworten", + view: "Öffnen", + }); + }); + + it("appends respond and view fallback links for non-ntfy providers", () => { + const result = renderNotificationActionPayload("https://hooks.slack.com/services/a/b/c", "Body", { + respondUrl: "https://app.example.com/api/notification-actions/respond-token", + viewUrl: "https://app.example.com/?date=2026-01-05", + }); + + expect(result.headers).toEqual({}); + expect(result.message).toBe( + "Body\n\nRespond:\nhttps://app.example.com/api/notification-actions/respond-token\n\nView:\nhttps://app.example.com/?date=2026-01-05" + ); + }); +}); diff --git a/backend/src/test/notification-actions-service.test.ts b/backend/src/test/notification-actions-service.test.ts new file mode 100644 index 0000000..7402699 --- /dev/null +++ b/backend/src/test/notification-actions-service.test.ts @@ -0,0 +1,225 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { migrate } from "drizzle-orm/libsql/migrator"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { runAlterMigrations } from "../db/db-utils.js"; + +const { testClient, testDb, mockedEnv } = vi.hoisted(() => { + const { createClient } = require("@libsql/client"); + const { drizzle } = require("drizzle-orm/libsql"); + const client = createClient({ url: ":memory:" }); + const db = drizzle(client); + + return { + testClient: client, + testDb: db, + mockedEnv: { + PUBLIC_APP_URL: "https://app.example.com", + CORS_ORIGINS: "http://localhost:5173,http://localhost:4173", + }, + }; +}); + +vi.mock("../db/client.js", () => ({ + db: testDb, + migrationsReady: Promise.resolve(), +})); + +vi.mock("../plugins/env.js", () => ({ env: mockedEnv })); + +const { createNotificationActionContext, getNotificationActionTokenRecord, hashActionToken } = await import( + "../services/notification-actions-service.js" +); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const migrationsFolder = resolve(__dirname, "../../drizzle"); + +function extractToken(url: string): string { + return url.split("/").at(-1) ?? ""; +} + +async function clearTables() { + await testClient.execute("DELETE FROM notification_action_tokens"); + await testClient.execute("DELETE FROM notification_action_groups"); + await testClient.execute("DELETE FROM users"); +} + +async function createUser(username: string) { + const result = await testClient.execute({ + sql: "INSERT INTO users (username, auth_provider, is_active) VALUES (?, 'local', 1) RETURNING id", + args: [username], + }); + + return Number(result.rows[0].id); +} + +describe("notification-actions-service", () => { + beforeAll(async () => { + await migrate(testDb, { migrationsFolder }); + await runAlterMigrations(testClient); + }); + + afterAll(() => { + testClient.close(); + }); + + beforeEach(async () => { + await clearTables(); + mockedEnv.PUBLIC_APP_URL = "https://app.example.com"; + mockedEnv.CORS_ORIGINS = "http://localhost:5173,http://localhost:4173"; + }); + + it("creates a notification action group with hashed tokens and app/view links", async () => { + const userId = await createUser("notify-actions-user"); + const scheduledFor = new Date("2026-01-05T08:00:00.000Z"); + + const context = await createNotificationActionContext({ + userId, + title: "Reminder", + message: "Take your medication now", + doseIds: ["9-1-1736064000000", "9-0-1736064000000", "9-1-1736064000000"], + scheduledFor, + publicAppUrl: mockedEnv.PUBLIC_APP_URL, + language: "en", + }); + + expect(context).toMatchObject({ + respondUrl: expect.stringContaining("/api/notification-actions/"), + viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=9-0-1736064000000", + sequenceId: expect.stringMatching(/^medassist-/), + }); + expect(context?.actions.map((action) => action.kind)).toEqual(["taken", "skip", "view"]); + + const groups = await testClient.execute({ + sql: "SELECT COUNT(*) AS count FROM notification_action_groups WHERE user_id = ?", + args: [userId], + }); + expect(Number(groups.rows[0].count)).toBe(1); + + const tokenRows = await testClient.execute({ + sql: "SELECT kind, token_hash FROM notification_action_tokens ORDER BY kind ASC", + }); + expect(tokenRows.rows).toHaveLength(3); + + const respondToken = extractToken(context!.respondUrl!); + const respondRow = tokenRows.rows.find((row: { kind?: unknown }) => row.kind === "respond"); + expect(respondRow).toEqual(expect.objectContaining({ token_hash: hashActionToken(respondToken), kind: "respond" })); + expect(respondRow?.token_hash).not.toBe(respondToken); + + const record = await getNotificationActionTokenRecord(respondToken); + expect(record).toMatchObject({ + doseIds: ["9-0-1736064000000", "9-1-1736064000000"], + viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=9-0-1736064000000", + }); + }); + + it("creates a view-only context without mutation tokens", async () => { + const userId = await createUser("notify-actions-view-only"); + const scheduledFor = new Date("2026-01-05T08:00:00.000Z"); + + const context = await createNotificationActionContext({ + userId, + title: "Grouped reminder", + message: "Open the dashboard for details", + doseIds: ["9-0-1736064000000", "10-0-1736064000000"], + scheduledFor, + publicAppUrl: mockedEnv.PUBLIC_APP_URL, + language: "en", + actionMode: "view-only", + }); + + expect(context).toEqual({ + viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=10-0-1736064000000", + actions: [ + { + kind: "view", + label: "View", + url: "https://app.example.com/dashboard?day=2026-01-05&dose=10-0-1736064000000", + method: "GET", + }, + ], + }); + + const groups = await testClient.execute("SELECT COUNT(*) AS count FROM notification_action_groups"); + expect(Number(groups.rows[0].count)).toBe(0); + + const tokens = await testClient.execute("SELECT COUNT(*) AS count FROM notification_action_tokens"); + expect(Number(tokens.rows[0].count)).toBe(0); + }); + + it("reuses an unresolved active group for the same dose set and schedule", async () => { + const userId = await createUser("notify-actions-reuse"); + const scheduledFor = new Date("2026-01-05T08:00:00.000Z"); + + const first = await createNotificationActionContext({ + userId, + title: "Reminder", + message: "Take your medication now", + doseIds: ["9-0-1736064000000"], + scheduledFor, + publicAppUrl: mockedEnv.PUBLIC_APP_URL, + language: "en", + }); + const second = await createNotificationActionContext({ + userId, + title: "Reminder", + message: "Take your medication now", + doseIds: ["9-0-1736064000000"], + scheduledFor, + publicAppUrl: mockedEnv.PUBLIC_APP_URL, + language: "en", + }); + + expect(second?.sequenceId).toBe(first?.sequenceId); + + const groups = await testClient.execute("SELECT id, sequence_id FROM notification_action_groups"); + expect(groups.rows).toHaveLength(1); + expect(groups.rows[0]).toEqual(expect.objectContaining({ sequence_id: first?.sequenceId })); + + const tokens = await testClient.execute("SELECT COUNT(*) AS count FROM notification_action_tokens"); + expect(Number(tokens.rows[0].count)).toBe(6); + }); + + it("prefers a non-local CORS origin when PUBLIC_APP_URL points to localhost", async () => { + const userId = await createUser("notify-actions-mobile"); + const scheduledFor = new Date("2026-01-05T08:00:00.000Z"); + mockedEnv.PUBLIC_APP_URL = "http://localhost:5173"; + mockedEnv.CORS_ORIGINS = "http://localhost:5173,http://192.168.0.113:5173"; + + const context = await createNotificationActionContext({ + userId, + title: "Reminder", + message: "Take your medication now", + doseIds: ["9-0-1736064000000"], + scheduledFor, + publicAppUrl: mockedEnv.PUBLIC_APP_URL, + language: "en", + }); + + expect(context).toMatchObject({ + respondUrl: `http://192.168.0.113:5173/api/notification-actions/${extractToken(context!.respondUrl!)}`, + viewUrl: "http://192.168.0.113:5173/dashboard?day=2026-01-05&dose=9-0-1736064000000", + }); + + const record = await getNotificationActionTokenRecord(extractToken(context!.respondUrl!)); + expect(record?.viewUrl).toBe("http://192.168.0.113:5173/dashboard?day=2026-01-05&dose=9-0-1736064000000"); + }); + + it("falls back to the date view when dose ids do not contain a medication id", async () => { + const userId = await createUser("notify-actions-fallback"); + const scheduledFor = new Date("2026-01-05T08:00:00.000Z"); + + const context = await createNotificationActionContext({ + userId, + title: "Reminder", + message: "Take your medication now", + doseIds: ["invalid-dose-id"], + scheduledFor, + publicAppUrl: mockedEnv.PUBLIC_APP_URL, + language: "en", + }); + + expect(context?.viewUrl).toBe("https://app.example.com/dashboard?day=2026-01-05&dose=invalid-dose-id"); + }); +});