diff --git a/backend/package-lock.json b/backend/package-lock.json index be03d26..ac7b027 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.2.0", + "@fastify/formbody": "^8.0.2", "@fastify/helmet": "^13.0.2", "@fastify/multipart": "^10.0.0", "@fastify/rate-limit": "^10.3.0", @@ -895,6 +896,26 @@ "fast-json-stringify": "^6.0.0" } }, + "node_modules/@fastify/formbody": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@fastify/formbody/-/formbody-8.0.2.tgz", + "integrity": "sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-querystring": "^1.1.2", + "fastify-plugin": "^5.0.0" + } + }, "node_modules/@fastify/forwarded": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", @@ -3130,9 +3151,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "funding": [ { "type": "github", diff --git a/backend/package.json b/backend/package.json index d8cea7a..d9f2447 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,6 +19,7 @@ "dependencies": { "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.2.0", + "@fastify/formbody": "^8.0.2", "@fastify/helmet": "^13.0.2", "@fastify/multipart": "^10.0.0", "@fastify/rate-limit": "^10.3.0", diff --git a/backend/src/index.ts b/backend/src/index.ts index 59e1229..576934f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -23,6 +23,7 @@ import { exportRoutes } from "./routes/export.js"; import { healthRoutes } from "./routes/health.js"; import { medicationEnrichmentRoutes } from "./routes/medication-enrichment.js"; import { medicationRoutes } from "./routes/medications.js"; +import { notificationActionRoutes } from "./routes/notification-actions.js"; import { oidcRoutes } from "./routes/oidc.js"; import { plannerRoutes } from "./routes/planner.js"; import { refillRoutes } from "./routes/refills.js"; @@ -79,6 +80,19 @@ function buildLoggerOptions(level: string) { return base; } +function buildHelmetOptions(_isProduction: boolean) { + return {}; +} + +function isPublicNotificationActionPath(url: string | undefined): boolean { + if (!url) { + return false; + } + + const normalizedUrl = url.split("?")[0]?.toLowerCase() ?? ""; + return /(^|\/)(api\/)?notification-actions(\/|$)/.test(normalizedUrl); +} + async function registerApiDocs(app: FastifyInstance, enabled: boolean) { if (!enabled) return; @@ -166,6 +180,7 @@ export async function createApp(options?: { app.addHook("onRequest", (request, reply, done) => { request.correlationId = request.id; reply.header("x-correlation-id", request.id); + done(); }); @@ -182,8 +197,26 @@ export async function createApp(options?: { // Register plugins await app.register(sensible); - await app.register(helmet); - await app.register(cors, { origin: opts.corsOrigins, credentials: true }); + await app.register(helmet, buildHelmetOptions(opts.isProduction)); + await app.register(cors, { + hook: "preHandler", + delegator: (request, callback) => { + if (isPublicNotificationActionPath(request.raw.url)) { + callback(null, { + origin: true, + credentials: false, + methods: ["GET", "HEAD", "POST", "OPTIONS"], + preflightContinue: true, + }); + return; + } + + callback(null, { + origin: opts.corsOrigins, + credentials: true, + }); + }, + }); await app.register(rateLimit, { max: 300, timeWindow: "1 minute" }); await app.register(cookie, { secret: opts.cookieSecret }); @@ -212,6 +245,7 @@ export async function createApp(options?: { await app.register(medicationEnrichmentRoutes); await app.register(settingsRoutes); await app.register(plannerRoutes); + await app.register(notificationActionRoutes); await app.register(shareRoutes); await app.register(doseRoutes); await app.register(exportRoutes); @@ -266,8 +300,26 @@ app.decorate("config", { }); await app.register(sensible); -await app.register(helmet); -await app.register(cors, { origin: origins, credentials: true }); +await app.register(helmet, buildHelmetOptions(env.NODE_ENV === "production")); +await app.register(cors, { + hook: "preHandler", + delegator: (request, callback) => { + if (isPublicNotificationActionPath(request.raw.url)) { + callback(null, { + origin: true, + credentials: false, + methods: ["GET", "HEAD", "POST", "OPTIONS"], + preflightContinue: true, + }); + return; + } + + callback(null, { + origin: origins, + credentials: true, + }); + }, +}); await app.register(rateLimit, { max: Number(process.env.RATE_LIMIT_MAX) || 100, timeWindow: "1 minute", @@ -294,6 +346,7 @@ await app.register(medicationRoutes); await app.register(medicationEnrichmentRoutes); await app.register(settingsRoutes); await app.register(plannerRoutes); +await app.register(notificationActionRoutes); await app.register(shareRoutes); await app.register(doseRoutes); await app.register(exportRoutes); diff --git a/backend/src/routes/notification-actions.ts b/backend/src/routes/notification-actions.ts new file mode 100644 index 0000000..2967daf --- /dev/null +++ b/backend/src/routes/notification-actions.ts @@ -0,0 +1,557 @@ +import formbody from "@fastify/formbody"; +import { eq } from "drizzle-orm"; +import type { FastifyInstance, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { db } from "../db/client.js"; +import { notificationActionGroups, notificationActionTokens, userSettings } from "../db/schema.js"; +import type { Language } from "../i18n/translations.js"; +import { markDoseTakenForUser, skipDosesForUser } from "../services/dose-tracking-service.js"; +import { + getNotificationActionTokenRecord, + isNotificationActionExpired, +} from "../services/notification-actions-service.js"; +import { getNotificationActionLabels } from "../services/notifications/action-renderer.js"; +import { sanitizeNotificationUrl } from "../services/settings-service.js"; +import { applyOpenApiRouteStandards, genericErrorSchema } from "../utils/openapi-route-standards.js"; + +const querySchema = z.object({ + action: z.enum(["taken", "skip", "dismiss"]).optional(), +}); + +type NotificationMutationAction = "taken" | "skip"; + +function normalizeNotificationAction(action: string | null | undefined): NotificationMutationAction | null { + if (action === "taken") { + return "taken"; + } + + if (action === "skip" || action === "dismiss") { + return "skip"; + } + + return null; +} + +const publicNotificationActionMethods = "GET,HEAD,POST,OPTIONS"; + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function toHtmlText(value: string): string { + return escapeHtml(value).replaceAll("\n", "
"); +} + +function getLanguage(language: string | null): Language { + return language === "de" ? "de" : "en"; +} + +function wantsHtml(request: FastifyRequest): boolean { + return request.headers.accept?.includes("text/html") ?? false; +} + +function applyPublicNotificationCorsHeaders( + request: FastifyRequest, + reply: { header: (name: string, value: string) => unknown } +) { + const requestOrigin = typeof request.headers.origin === "string" ? request.headers.origin : "*"; + reply.header("access-control-allow-origin", requestOrigin); + reply.header("access-control-allow-methods", publicNotificationActionMethods); + reply.header("access-control-allow-headers", "content-type"); + if (requestOrigin !== "*") { + reply.header("vary", "Origin"); + } +} + +function getAlreadyProcessedText(language: Language, resolvedAction: NotificationMutationAction) { + if (resolvedAction === "taken") { + return { + bodyTitle: language === "de" ? "Bereits verarbeitet" : "Already processed", + bodyText: + language === "de" + ? "Diese Einnahme ist bereits als genommen markiert. Wenn Sie das ändern möchten, öffnen Sie MedAssist und machen Sie die Einnahme dort rückgängig." + : "This dose is already marked as taken. If you need to change it, open MedAssist and undo it there.", + jsonMessage: + language === "de" + ? "Diese Einnahme ist bereits als genommen markiert. Änderungen sind nur in MedAssist möglich." + : "This dose is already marked as taken. Changes can only be made in MedAssist.", + }; + } + + return { + bodyTitle: language === "de" ? "Bereits verarbeitet" : "Already processed", + bodyText: + language === "de" + ? "Diese Einnahme ist bereits als übersprungen markiert. Wenn Sie sie stattdessen als genommen markieren möchten, öffnen Sie MedAssist und machen Sie das dort." + : "This intake is already marked as skipped. If you want to mark it as taken instead, open MedAssist and do that there.", + jsonMessage: + language === "de" + ? "Diese Einnahme ist bereits als übersprungen markiert. Änderungen sind nur in MedAssist möglich." + : "This intake is already marked as skipped. Changes can only be made in MedAssist.", + }; +} + +function getActionRecordedText(language: Language, action: NotificationMutationAction) { + if (action === "taken") { + return { + bodyTitle: language === "de" ? "Aktion gespeichert" : "Action recorded", + bodyText: language === "de" ? "Die Einnahme wurde als genommen markiert." : "The dose was marked as taken.", + }; + } + + return { + bodyTitle: language === "de" ? "Aktion gespeichert" : "Action recorded", + bodyText: language === "de" ? "Die Einnahme wurde als übersprungen markiert." : "The intake was marked as skipped.", + }; +} + +async function clearNtfyNotificationSequence(userId: number, sequenceId: string): Promise { + const [settings] = await db + .select({ shoutrrrEnabled: userSettings.shoutrrrEnabled, shoutrrrUrl: userSettings.shoutrrrUrl }) + .from(userSettings) + .where(eq(userSettings.userId, userId)); + + if (!settings?.shoutrrrEnabled || !settings.shoutrrrUrl) { + return; + } + + const sanitized = sanitizeNotificationUrl(settings.shoutrrrUrl); + if ("error" in sanitized || !sanitized.isNtfy) { + return; + } + + const clearUrl = new URL(sanitized.url); + clearUrl.pathname = `${clearUrl.pathname.replace(/\/+$/, "")}/${encodeURIComponent(sequenceId)}/clear`; + + const headers: Record = {}; + if (sanitized.auth) { + headers.Authorization = `Basic ${Buffer.from(`${sanitized.auth.user}:${sanitized.auth.pass}`).toString("base64")}`; + } + + const response = await fetch(clearUrl.toString(), { + method: "PUT", + headers, + redirect: "error", + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP ${response.status}: ${errorText}`); + } +} + +async function deleteNtfyNotificationSequence(userId: number, sequenceId: string): Promise { + const normalizedSequenceId = sequenceId.trim(); + if (normalizedSequenceId.length === 0) { + return; + } + + const [settings] = await db + .select({ shoutrrrEnabled: userSettings.shoutrrrEnabled, shoutrrrUrl: userSettings.shoutrrrUrl }) + .from(userSettings) + .where(eq(userSettings.userId, userId)); + + if (!settings?.shoutrrrEnabled || !settings.shoutrrrUrl) { + return; + } + + const sanitized = sanitizeNotificationUrl(settings.shoutrrrUrl); + if ("error" in sanitized || !sanitized.isNtfy) { + return; + } + + const deleteUrl = new URL(sanitized.url); + deleteUrl.pathname = `${deleteUrl.pathname.replace(/\/+$/, "")}/${encodeURIComponent(normalizedSequenceId)}`; + + const headers: Record = {}; + if (sanitized.auth) { + headers.Authorization = `Basic ${Buffer.from(`${sanitized.auth.user}:${sanitized.auth.pass}`).toString("base64")}`; + } + + const response = await fetch(deleteUrl.toString(), { + method: "DELETE", + headers, + redirect: "error", + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP ${response.status}: ${errorText}`); + } +} + +function renderPage(options: { + language: Language; + title: string; + message: string; + bodyTitle: string; + bodyText: string; + viewUrl: string | null; + actionButtons: Array<{ label: string; formAction?: string }>; +}): string { + const labels = getNotificationActionLabels(options.language); + const forms = + options.actionButtons.length > 0 + ? `
${options.actionButtons + .map((button) => { + const formAction = button.formAction ? ` action="${escapeHtml(button.formAction)}"` : ""; + return `
`; + }) + .join("")}
` + : ""; + const viewLink = options.viewUrl + ? `

${escapeHtml(labels.view)}

` + : ""; + + return ` + + + + + ${escapeHtml(options.bodyTitle)} + + + +
+

${escapeHtml(options.bodyTitle)}

+

${escapeHtml(options.bodyText)}

+
+ ${escapeHtml(options.title)} +

${toHtmlText(options.message)}

+
+ ${forms} + ${viewLink} +
+ +`; +} + +function parseRequestedAction(request: FastifyRequest, tokenKind: string): NotificationMutationAction | null { + const normalizedTokenAction = normalizeNotificationAction(tokenKind); + if (normalizedTokenAction) { + return normalizedTokenAction; + } + + const parsedQuery = querySchema.safeParse(request.query); + if (parsedQuery.success && parsedQuery.data.action) { + return normalizeNotificationAction(parsedQuery.data.action); + } + + const body = request.body; + if (body && typeof body === "object" && "action" in body) { + const actionValue = (body as { action?: unknown }).action; + if (typeof actionValue === "string") { + return normalizeNotificationAction(actionValue); + } + } + + return null; +} + +function buildNotificationActionLogContext( + record: Awaited> extends infer T ? Exclude : never, + extra: Record = {} +) { + return { + groupId: record.group.id, + userId: record.group.userId, + tokenKind: record.token.kind, + doseCount: record.doseIds.length, + hasViewUrl: record.viewUrl !== null, + ...extra, + }; +} + +function buildNotificationRequestLogContext(request: FastifyRequest, extra: Record = {}) { + return { + method: request.method, + hasOrigin: typeof request.headers.origin === "string", + expectsHtml: wantsHtml(request), + ...extra, + }; +} + +export async function notificationActionRoutes(app: FastifyInstance) { + await app.register(formbody); + + applyOpenApiRouteStandards(app, { tag: "notification-actions", protectedByDefault: false }); + + app.options<{ Params: { token: string } }>("/notification-actions/:token", async (request, reply) => { + applyPublicNotificationCorsHeaders(request, reply); + return reply.status(204).send(); + }); + + app.get<{ Params: { token: string } }>( + "/notification-actions/:token", + { + config: { + rateLimit: { max: 30, timeWindow: "1 minute" }, + }, + schema: { + tags: ["notification-actions"], + params: { + type: "object", + required: ["token"], + properties: { token: { type: "string", minLength: 1 } }, + }, + response: { + 404: genericErrorSchema, + 405: genericErrorSchema, + 410: genericErrorSchema, + }, + }, + }, + async (request, reply) => { + applyPublicNotificationCorsHeaders(request, reply); + + const record = await getNotificationActionTokenRecord(request.params.token); + if (!record) { + request.log.warn( + buildNotificationRequestLogContext(request), + "[NotificationActions] Unknown notification action token requested" + ); + return reply.status(404).send({ error: "Notification action not found" }); + } + + if (isNotificationActionExpired(record)) { + request.log.warn( + buildNotificationActionLogContext(record), + "[NotificationActions] Rejected expired notification action GET request" + ); + return reply.status(410).send({ error: "Notification action has expired" }); + } + + if (record.token.kind !== "respond" && record.group.resolvedAction === null) { + request.log.warn( + buildNotificationActionLogContext(record), + "[NotificationActions] Rejected direct GET for unresolved non-respond token" + ); + return reply.status(405).send({ error: "Direct GET is only available for respond actions" }); + } + + const language = getLanguage(record.group.language ?? null); + const labels = getNotificationActionLabels(language); + const resolvedAction = normalizeNotificationAction(record.group.resolvedAction); + let bodyTitle: string; + let bodyText: string; + let actionButtons: Array<{ label: string; formAction?: string }> = []; + + if (resolvedAction) { + ({ bodyTitle, bodyText } = getAlreadyProcessedText(language, resolvedAction)); + } else { + if (record.token.kind === "taken") { + bodyTitle = language === "de" ? "Einnahme bestätigen" : "Confirm dose"; + bodyText = + language === "de" + ? "Bestätigen Sie, dass diese Einnahme als genommen markiert werden soll." + : "Confirm that this dose should be marked as taken."; + actionButtons = [{ label: labels.taken }]; + } else if (record.token.kind === "skip" || record.token.kind === "dismiss") { + bodyTitle = language === "de" ? "Einnahme überspringen" : "Skip intake"; + bodyText = + language === "de" + ? "Bestätigen Sie, dass diese Einnahme als übersprungen markiert werden soll." + : "Confirm that this intake should be marked as skipped."; + actionButtons = [{ label: labels.skip }]; + } else { + bodyTitle = language === "de" ? "Erinnerung beantworten" : "Respond to reminder"; + bodyText = + language === "de" + ? "Wählen Sie eine Aktion für diese Medikamentenerinnerung." + : "Choose an action for this medication reminder."; + actionButtons = [ + { label: labels.taken, formAction: "?action=taken" }, + { label: labels.skip, formAction: "?action=skip" }, + ]; + } + } + + return reply.type("text/html; charset=utf-8").send( + renderPage({ + language, + title: record.group.title, + message: record.group.message, + bodyTitle, + bodyText, + viewUrl: record.viewUrl, + actionButtons: resolvedAction ? [] : actionButtons, + }) + ); + } + ); + + app.post<{ Params: { token: string } }>( + "/notification-actions/:token", + { + config: { + rateLimit: { max: 30, timeWindow: "1 minute" }, + }, + schema: { + tags: ["notification-actions"], + params: { + type: "object", + required: ["token"], + properties: { token: { type: "string", minLength: 1 } }, + }, + response: { + 400: genericErrorSchema, + 404: genericErrorSchema, + 409: genericErrorSchema, + 410: genericErrorSchema, + }, + }, + }, + async (request, reply) => { + applyPublicNotificationCorsHeaders(request, reply); + + const record = await getNotificationActionTokenRecord(request.params.token); + if (!record) { + request.log.warn( + buildNotificationRequestLogContext(request), + "[NotificationActions] Unknown notification action token requested" + ); + return reply.status(404).send({ error: "Notification action not found" }); + } + + if (isNotificationActionExpired(record)) { + request.log.warn( + buildNotificationActionLogContext(record), + "[NotificationActions] Rejected expired notification action POST request" + ); + return reply.status(410).send({ error: "Notification action has expired" }); + } + + const action = parseRequestedAction(request, record.token.kind); + if (!action) { + request.log.warn( + buildNotificationActionLogContext(record), + "[NotificationActions] Missing or invalid action for notification action POST request" + ); + return reply.status(400).send({ error: "Notification action is required" }); + } + + const language = getLanguage(record.group.language ?? null); + const resolvedAction = normalizeNotificationAction(record.group.resolvedAction); + if (resolvedAction) { + request.log.info( + buildNotificationActionLogContext(record, { requestedAction: action, resolvedAction }), + "[NotificationActions] Ignored notification action because it was already resolved" + ); + const alreadyProcessedText = getAlreadyProcessedText(language, resolvedAction); + + if (wantsHtml(request)) { + return reply.type("text/html; charset=utf-8").send( + renderPage({ + language, + title: record.group.title, + message: record.group.message, + bodyTitle: alreadyProcessedText.bodyTitle, + bodyText: alreadyProcessedText.bodyText, + viewUrl: record.viewUrl, + actionButtons: [], + }) + ); + } + + return reply.send({ + success: true, + action: resolvedAction, + alreadyProcessed: true, + message: alreadyProcessedText.jsonMessage, + }); + } + + if (action === "taken") { + for (const [doseIndex, doseId] of record.doseIds.entries()) { + const result = await markDoseTakenForUser({ + userId: record.group.userId, + doseId, + source: "notification", + markedBy: null, + }); + + if (!result.success) { + request.log.warn( + buildNotificationActionLogContext(record, { + requestedAction: action, + failedDoseIndex: doseIndex, + code: result.code, + }), + "[NotificationActions] Failed to record taken notification action" + ); + const statusCode = result.code === "INVALID_DOSE" ? 400 : 409; + return reply.status(statusCode).send({ error: result.message, code: result.code }); + } + } + } else { + await skipDosesForUser({ userId: record.group.userId, doseIds: record.doseIds }); + } + + await db + .update(notificationActionGroups) + .set({ resolvedAction: action, resolvedAt: new Date(), updatedAt: new Date() }) + .where(eq(notificationActionGroups.id, record.group.id)); + await db + .update(notificationActionTokens) + .set({ usedAt: new Date() }) + .where(eq(notificationActionTokens.id, record.token.id)); + + request.log.info( + buildNotificationActionLogContext(record, { requestedAction: action }), + "[NotificationActions] Recorded notification action" + ); + + const recordedText = getActionRecordedText(language, action); + + try { + await deleteNtfyNotificationSequence(record.group.userId, record.group.sequenceId); + } catch (error) { + request.log.warn( + buildNotificationActionLogContext(record, { requestedAction: action, error }), + "[NotificationActions] Failed to delete ntfy notification after resolved action" + ); + try { + await clearNtfyNotificationSequence(record.group.userId, record.group.sequenceId); + } catch (clearError) { + request.log.warn( + buildNotificationActionLogContext(record, { requestedAction: action, error: clearError }), + "[NotificationActions] Failed to clear ntfy notification after delete fallback" + ); + } + } + + if (wantsHtml(request)) { + return reply.type("text/html; charset=utf-8").send( + renderPage({ + language, + title: record.group.title, + message: record.group.message, + bodyTitle: recordedText.bodyTitle, + bodyText: recordedText.bodyText, + viewUrl: record.viewUrl, + actionButtons: [], + }) + ); + } + + return reply.send({ success: true, action }); + } + ); +} diff --git a/backend/src/test/notification-actions-route.test.ts b/backend/src/test/notification-actions-route.test.ts new file mode 100644 index 0000000..f5dcb76 --- /dev/null +++ b/backend/src/test/notification-actions-route.test.ts @@ -0,0 +1,567 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { migrate } from "drizzle-orm/libsql/migrator"; +import Fastify from "fastify"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { runAlterMigrations } from "../db/db-utils.js"; +import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js"; + +const { testClient, testDb, mockedEnv, fetchMock, mockLogger } = vi.hoisted(() => { + const { createClient } = require("@libsql/client"); + const { drizzle } = require("drizzle-orm/libsql"); + const client = createClient({ url: ":memory:" }); + const db = drizzle(client); + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + silent: vi.fn(), + level: "info", + child: vi.fn(), + }; + logger.child.mockImplementation(() => logger); + + return { + testClient: client, + testDb: db, + fetchMock: vi.fn(), + mockLogger: logger, + mockedEnv: { + AUTH_ENABLED: false, + OIDC_ENABLED: false, + OIDC_PROVIDER_NAME: "SSO", + NODE_ENV: "test", + LOG_LEVEL: "silent", + PORT: 3000, + CORS_ORIGINS: "*", + PUBLIC_APP_URL: "https://app.example.com", + OPENAPI_DOCS_ENABLED: false, + }, + }; +}); + +global.fetch = fetchMock; + +vi.mock("../db/client.js", () => ({ + db: testDb, + migrationsReady: Promise.resolve(), +})); + +vi.mock("../plugins/env.js", () => ({ env: mockedEnv })); + +const { notificationActionRoutes } = await import("../routes/notification-actions.js"); +const { createNotificationActionContext } = 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 dose_tracking"); + await testClient.execute("DELETE FROM notification_action_tokens"); + await testClient.execute("DELETE FROM notification_action_groups"); + await testClient.execute("DELETE FROM medications"); + await testClient.execute("DELETE FROM user_settings"); + 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); +} + +async function insertMedication(options: { id: number; userId: number; packCount?: number; looseTablets?: number }) { + const start = "2026-01-05T08:00:00.000Z"; + await testClient.execute({ + sql: `INSERT INTO medications ( + id, user_id, name, taken_by_json, medication_form, package_type, + pack_count, blisters_per_pack, pills_per_blister, loose_tablets, stock_adjustment, + usage_json, every_json, start_json, intakes_json, intake_reminders_enabled + ) VALUES (?, ?, 'Route Medication', '[]', 'tablet', 'blister', ?, 1, 10, ?, 0, ?, ?, ?, ?, 1)`, + args: [ + options.id, + options.userId, + options.packCount ?? 1, + options.looseTablets ?? 0, + JSON.stringify([1]), + JSON.stringify([1]), + JSON.stringify([start]), + JSON.stringify([{ usage: 1, every: 1, start, takenBy: null, intakeRemindersEnabled: true }]), + ], + }); +} + +async function insertUserSettings( + userId: number, + stockCalculationMode: "automatic" | "manual" = "automatic", + overrides: { shoutrrrEnabled?: boolean; shoutrrrUrl?: string | null } = {} +) { + await testClient.execute({ + sql: "INSERT INTO user_settings (user_id, stock_calculation_mode, shoutrrr_enabled, shoutrrr_url) VALUES (?, ?, ?, ?)", + args: [userId, stockCalculationMode, overrides.shoutrrrEnabled ? 1 : 0, overrides.shoutrrrUrl ?? null], + }); +} + +async function seedContext(options: { userId: number; doseId: string }) { + const scheduledFor = new Date("2026-01-05T08:00:00.000Z"); + const context = await createNotificationActionContext({ + userId: options.userId, + title: "Reminder", + message: "Take your medication now", + doseIds: [options.doseId], + scheduledFor, + publicAppUrl: mockedEnv.PUBLIC_APP_URL, + language: "en", + }); + + return { + respondToken: extractToken(context!.respondUrl!), + takenToken: extractToken(context!.actions.find((action) => action.kind === "taken")!.url), + skipToken: extractToken(context!.actions.find((action) => action.kind === "skip")!.url), + context: context!, + }; +} + +describe("notification action routes", () => { + let app: Awaited>; + + beforeAll(async () => { + await migrate(testDb, { migrationsFolder }); + await runAlterMigrations(testClient); + app = Fastify({ loggerInstance: mockLogger, disableRequestLogging: true, ajv: documentationSchemaAjv }); + await app.register(notificationActionRoutes); + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + testClient.close(); + }); + + beforeEach(async () => { + await clearTables(); + mockedEnv.PUBLIC_APP_URL = "https://app.example.com"; + mockedEnv.NODE_ENV = "test"; + fetchMock.mockReset(); + fetchMock.mockResolvedValue({ ok: true }); + mockLogger.info.mockClear(); + mockLogger.warn.mockClear(); + mockLogger.error.mockClear(); + mockLogger.debug.mockClear(); + mockLogger.trace.mockClear(); + mockLogger.fatal.mockClear(); + mockLogger.child.mockClear(); + }); + + it("renders HTML for respond tokens without mutating state", async () => { + const userId = await createUser("notification-route-get"); + const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" }); + + const response = await app.inject({ + method: "GET", + url: `/notification-actions/${respondToken}`, + headers: { accept: "text/html" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers["content-type"]).toContain("text/html"); + expect(response.body).toContain("Respond to reminder"); + expect(response.body).toContain("Take your medication now"); + + const rows = await testClient.execute({ + sql: `SELECT g.resolved_action, t.used_at + FROM notification_action_groups g + INNER JOIN notification_action_tokens t ON t.group_id = g.id + WHERE t.kind = 'respond'`, + }); + expect(rows.rows).toEqual([expect.objectContaining({ resolved_action: null, used_at: null })]); + }); + + it("returns the expected GET behavior for missing, non-respond, and expired tokens", async () => { + const missing = await app.inject({ method: "GET", url: "/notification-actions/missing-token" }); + expect(missing.statusCode).toBe(404); + + const userId = await createUser("notification-route-errors"); + const { respondToken, takenToken } = await seedContext({ userId, doseId: "5-0-1736064000000" }); + + const nonRespond = await app.inject({ + method: "GET", + url: `/notification-actions/${takenToken}`, + headers: { accept: "text/html" }, + }); + expect(nonRespond.statusCode).toBe(405); + expect(nonRespond.json()).toEqual({ error: "Direct GET is only available for respond actions" }); + + await testClient.execute({ + sql: "UPDATE notification_action_groups SET expires_at = ?", + args: [new Date(0)], + }); + + const expired = await app.inject({ method: "GET", url: `/notification-actions/${respondToken}` }); + expect(expired.statusCode).toBe(410); + expect(expired.json()).toEqual({ error: "Notification action has expired" }); + }); + + it("shows an already-processed HTML state for resolved respond tokens", async () => { + const userId = await createUser("notification-route-resolved"); + const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" }); + await testClient.execute({ + sql: "UPDATE notification_action_groups SET resolved_action = 'skip', resolved_at = ?", + args: [new Date()], + }); + + const response = await app.inject({ + method: "GET", + url: `/notification-actions/${respondToken}`, + headers: { accept: "text/html" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain("Already processed"); + expect(response.body).toContain( + "This intake is already marked as skipped. If you want to mark it as taken instead, open MedAssist and do that there." + ); + }); + + it("skips doses through a respond token and returns friendly success for already-resolved follow-up actions", async () => { + const userId = await createUser("notification-route-skip"); + const { respondToken, takenToken } = await seedContext({ userId, doseId: "5-0-1736064000000" }); + + const response = await app.inject({ + method: "POST", + url: `/notification-actions/${respondToken}`, + payload: { action: "skip" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true, action: "skip" }); + expect(mockLogger.info).toHaveBeenCalledWith( + expect.objectContaining({ + userId, + groupId: expect.any(Number), + tokenKind: "respond", + doseCount: 1, + hasViewUrl: true, + requestedAction: "skip", + }), + "[NotificationActions] Recorded notification action" + ); + + const dismissedRow = await testClient.execute({ + sql: "SELECT dismissed, taken_at FROM dose_tracking WHERE user_id = ? AND dose_id = ?", + args: [userId, "5-0-1736064000000"], + }); + expect(dismissedRow.rows).toEqual([expect.objectContaining({ dismissed: 1, taken_at: 0 })]); + + const groupRow = await testClient.execute({ + sql: "SELECT resolved_action FROM notification_action_groups", + }); + expect(groupRow.rows).toEqual([expect.objectContaining({ resolved_action: "skip" })]); + + const conflict = await app.inject({ + method: "POST", + url: `/notification-actions/${takenToken}`, + }); + + expect(conflict.statusCode).toBe(200); + expect(conflict.json()).toEqual({ + success: true, + action: "skip", + alreadyProcessed: true, + message: "This intake is already marked as skipped. Changes can only be made in MedAssist.", + }); + }); + + it("keeps legacy dismiss respond actions working as a skip alias", async () => { + const userId = await createUser("notification-route-dismiss-alias"); + const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" }); + + const response = await app.inject({ + method: "POST", + url: `/notification-actions/${respondToken}`, + payload: { action: "dismiss" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true, action: "skip" }); + + const groupRow = await testClient.execute({ + sql: "SELECT resolved_action FROM notification_action_groups", + }); + expect(groupRow.rows).toEqual([expect.objectContaining({ resolved_action: "skip" })]); + }); + + it("returns an undo hint when a reminder was already taken before a follow-up skip action", async () => { + const userId = await createUser("notification-route-taken-followup"); + await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 }); + await insertUserSettings(userId, "automatic"); + const { takenToken, respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" }); + + const firstResponse = await app.inject({ + method: "POST", + url: `/notification-actions/${takenToken}`, + }); + + expect(firstResponse.statusCode).toBe(200); + expect(firstResponse.json()).toEqual({ success: true, action: "taken" }); + + const followUpHtml = await app.inject({ + method: "GET", + url: `/notification-actions/${respondToken}`, + headers: { accept: "text/html" }, + }); + + expect(followUpHtml.statusCode).toBe(200); + expect(followUpHtml.body).toContain( + "This dose is already marked as taken. If you need to change it, open MedAssist and undo it there." + ); + + const followUpJson = await app.inject({ + method: "POST", + url: `/notification-actions/${respondToken}`, + payload: { action: "skip" }, + }); + + expect(followUpJson.statusCode).toBe(200); + expect(followUpJson.json()).toEqual({ + success: true, + action: "taken", + alreadyProcessed: true, + message: "This dose is already marked as taken. Changes can only be made in MedAssist.", + }); + }); + + it("deletes the original ntfy notification after a successful action", async () => { + const userId = await createUser("notification-route-ntfy-delete"); + await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 }); + await insertUserSettings(userId, "automatic", { + shoutrrrEnabled: true, + shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist", + }); + const { takenToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" }); + fetchMock.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve("") }); + + const response = await app.inject({ + method: "POST", + url: `/notification-actions/${takenToken}`, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [targetUrl, requestInit] = fetchMock.mock.calls[0] ?? []; + expect(targetUrl).toBe(`https://ntfy.example.com/medassist/${context.sequenceId}`); + expect(requestInit).toEqual( + expect.objectContaining({ + method: "DELETE", + redirect: "error", + headers: expect.objectContaining({ + Authorization: expect.stringMatching(/^Basic /), + }), + }) + ); + }); + + it("deletes the original ntfy notification after a skip action", async () => { + const userId = await createUser("notification-route-ntfy-skip-delete"); + await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 }); + await insertUserSettings(userId, "automatic", { + shoutrrrEnabled: true, + shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist", + }); + const { skipToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" }); + fetchMock.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve("") }); + + const response = await app.inject({ + method: "POST", + url: `/notification-actions/${skipToken}`, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [targetUrl, requestInit] = fetchMock.mock.calls[0] ?? []; + expect(targetUrl).toBe(`https://ntfy.example.com/medassist/${context.sequenceId}`); + expect(requestInit).toEqual( + expect.objectContaining({ + method: "DELETE", + redirect: "error", + headers: expect.objectContaining({ + Authorization: expect.stringMatching(/^Basic /), + }), + }) + ); + }); + + it("warns when ntfy delete and fallback clear both fail", async () => { + const userId = await createUser("notification-route-ntfy-delete-warn"); + await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 }); + await insertUserSettings(userId, "automatic", { + shoutrrrEnabled: true, + shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist", + }); + const { takenToken } = await seedContext({ userId, doseId: "5-0-1736064000000" }); + fetchMock.mockResolvedValueOnce({ ok: false, status: 500, text: () => Promise.resolve("upstream down") }); + fetchMock.mockResolvedValueOnce({ ok: false, status: 404, text: () => Promise.resolve("not found") }); + + const response = await app.inject({ + method: "POST", + url: `/notification-actions/${takenToken}`, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(app.log.warn).toHaveBeenCalledWith( + expect.objectContaining({ requestedAction: "taken" }), + expect.stringContaining("Failed to delete ntfy notification after resolved action") + ); + expect(app.log.warn).toHaveBeenCalledWith( + expect.objectContaining({ requestedAction: "taken" }), + expect.stringContaining("Failed to clear ntfy notification after delete fallback") + ); + }); + + it("falls back to clear when deleting the ntfy notification fails", async () => { + const userId = await createUser("notification-route-ntfy-delete-clear-fallback"); + await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 }); + await insertUserSettings(userId, "automatic", { + shoutrrrEnabled: true, + shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist", + }); + const { takenToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" }); + fetchMock.mockResolvedValueOnce({ ok: false, status: 404, text: () => Promise.resolve("missing") }); + fetchMock.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve("") }); + + const response = await app.inject({ + method: "POST", + url: `/notification-actions/${takenToken}`, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(2); + + const [clearUrl, clearInit] = fetchMock.mock.calls[1] ?? []; + expect(clearUrl).toBe(`https://ntfy.example.com/medassist/${context.sequenceId}/clear`); + expect(clearInit).toEqual( + expect.objectContaining({ + method: "PUT", + headers: expect.objectContaining({ Authorization: expect.stringMatching(/^Basic /) }), + }) + ); + }); + + it("allows browser-origin CORS requests for public notification action tokens", async () => { + const userId = await createUser("notification-route-cors"); + const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" }); + + const preflight = await app.inject({ + method: "OPTIONS", + url: `/notification-actions/${respondToken}?action=taken`, + headers: { + origin: "https://ntfy.danielvolz.org", + "access-control-request-method": "POST", + "access-control-request-headers": "content-type", + }, + }); + + expect(preflight.statusCode).toBe(204); + expect(preflight.headers["access-control-allow-origin"]).toBe("https://ntfy.danielvolz.org"); + expect(preflight.headers["access-control-allow-methods"]).toContain("POST"); + expect(preflight.headers["access-control-allow-headers"]).toContain("content-type"); + + const response = await app.inject({ + method: "POST", + url: `/notification-actions/${respondToken}`, + headers: { + origin: "https://ntfy.danielvolz.org", + }, + payload: { action: "skip" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers["access-control-allow-origin"]).toBe("https://ntfy.danielvolz.org"); + }); + + it("accepts standard HTML form posts on respond pages", async () => { + const userId = await createUser("notification-route-form-post"); + await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 }); + await insertUserSettings(userId, "automatic"); + const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" }); + + const response = await app.inject({ + method: "POST", + url: `/notification-actions/${respondToken}?action=taken`, + headers: { + accept: "text/html", + "content-type": "application/x-www-form-urlencoded", + }, + payload: "", + }); + + expect(response.statusCode).toBe(200); + expect(response.headers["content-type"]).toContain("text/html"); + expect(response.body).toContain("Action recorded"); + expect(response.body).toContain("The dose was marked as taken."); + }); + + it("returns non-2xx for invalid, expired, and out-of-stock POST actions", async () => { + const missing = await app.inject({ method: "POST", url: "/notification-actions/missing-token" }); + expect(missing.statusCode).toBe(404); + + const expiredUserId = await createUser("notification-route-expired"); + const { respondToken } = await seedContext({ userId: expiredUserId, doseId: "5-0-1736064000000" }); + await testClient.execute({ + sql: "UPDATE notification_action_groups SET expires_at = ? WHERE user_id = ?", + args: [new Date(0), expiredUserId], + }); + + const expired = await app.inject({ + method: "POST", + url: `/notification-actions/${respondToken}`, + payload: { action: "skip" }, + }); + expect(expired.statusCode).toBe(410); + + const userId = await createUser("notification-route-stock"); + await insertMedication({ id: 5, userId, packCount: 0, looseTablets: 0 }); + await insertUserSettings(userId, "automatic"); + const { takenToken } = await seedContext({ userId, doseId: "5-0-1736064000000" }); + + const outOfStock = await app.inject({ + method: "POST", + url: `/notification-actions/${takenToken}`, + }); + expect(outOfStock.statusCode).toBe(409); + expect(outOfStock.json()).toEqual({ error: "Medication is out of stock", code: "OUT_OF_STOCK" }); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + userId, + groupId: expect.any(Number), + tokenKind: "taken", + doseCount: 1, + hasViewUrl: true, + requestedAction: "taken", + failedDoseIndex: 0, + code: "OUT_OF_STOCK", + }), + "[NotificationActions] Failed to record taken notification action" + ); + + const state = await testClient.execute({ + sql: "SELECT resolved_action FROM notification_action_groups WHERE user_id = ?", + args: [userId], + }); + expect(state.rows).toEqual([expect.objectContaining({ resolved_action: null })]); + }); +}); diff --git a/backend/src/test/server.test.ts b/backend/src/test/server.test.ts index 3a6e899..e2f27d6 100644 --- a/backend/src/test/server.test.ts +++ b/backend/src/test/server.test.ts @@ -244,6 +244,46 @@ describe("Server Bootstrap", () => { await app.close(); }); + it("should allow browser preflight requests on public notification action routes", async () => { + const origins = ["https://medtest.danielvolz.org"]; + + const app = Fastify({ logger: false, ajv: documentationSchemaAjv }); + await app.register(cors, { + delegator: (request, callback) => { + if (request.raw.url?.startsWith("/notification-actions/")) { + callback(null, { + origin: true, + credentials: false, + methods: ["GET", "HEAD", "POST", "OPTIONS"], + }); + return; + } + + callback(null, { origin: origins, credentials: true }); + }, + }); + + app.post("/notification-actions/:token", async () => ({ ok: true })); + await app.ready(); + + const response = await app.inject({ + method: "OPTIONS", + url: "/notification-actions/demo-token", + headers: { + origin: "https://ntfy.danielvolz.org", + "access-control-request-method": "POST", + "access-control-request-headers": "content-type", + }, + }); + + expect(response.statusCode).toBe(204); + expect(response.headers["access-control-allow-origin"]).toBe("https://ntfy.danielvolz.org"); + expect(response.headers["access-control-allow-credentials"]).toBeUndefined(); + expect(response.headers["access-control-allow-methods"]).toContain("OPTIONS"); + + await app.close(); + }); + it("should register cookie plugin", async () => { const app = Fastify({ logger: false, ajv: documentationSchemaAjv }); await app.register(cookie, { secret: "test-cookie-secret" });