From d5fc18c9af0f85fb46cdf7064b910ec6cafa484f Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Sun, 10 May 2026 21:37:59 +0200 Subject: [PATCH] chore: trim ntfy recovery PR to shipping scope --- .../test/notification-action-renderer.test.ts | 186 --------------- .../test/notification-actions-service.test.ts | 225 ------------------ 2 files changed, 411 deletions(-) delete mode 100644 backend/src/test/notification-action-renderer.test.ts delete mode 100644 backend/src/test/notification-actions-service.test.ts diff --git a/backend/src/test/notification-action-renderer.test.ts b/backend/src/test/notification-action-renderer.test.ts deleted file mode 100644 index 14dbaac..0000000 --- a/backend/src/test/notification-action-renderer.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -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 deleted file mode 100644 index 7402699..0000000 --- a/backend/src/test/notification-actions-service.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -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"); - }); -});