Files
HealthLog/tests/integration/notification-action-mood.test.ts
T
Marc-André Bombeck cc507deafa fix(notifications): log every refused notification answer
A quick answer whose request never reached the route left no trace: a
redirect in front of the app turns ntfy's POST into a GET, which Next
answered 405 before any wide event, and the worker-only and demo-mode
refusals in the proxy wrote nothing either. The proxy now answers a
non-POST on the action path itself with 405 and Allow: POST, and every
refusal it makes there writes one warn line with a reason and the path
without its token. The route's rate-limit refusal carries a reason too.

The regression test sends the action ntfy publishes, in the shape ntfy
sends it, through the real proxy and route against Postgres.

Refs #1203
2026-10-14 18:57:06 +02:00

202 lines
7.0 KiB
TypeScript

/**
* v1.42.2 — a mood quick answer from a notification writes exactly once.
*
* The three answers on one reminder share a nonce, and the write is keyed on
* it through `MoodEntry`'s unique `(userId, source, externalId)`. Against
* real Postgres and the real route: the first answer logs, a repeat of the
* same answer, a different answer on the same reminder, and two answers
* racing each other all leave exactly one row behind.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
const USER = "user-notification-action";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
function call(token: string) {
return import("@/app/api/notifications/action/[token]/route").then(
({ POST }) =>
POST(
new NextRequest(`http://localhost/api/notifications/action/${token}`, {
method: "POST",
}),
{ params: Promise.resolve({ token }) },
),
);
}
async function tokens(nonce: string) {
const { mintActionToken } = await import("@/lib/notifications/action-token");
return {
good: mintActionToken({ userId: USER, action: "mood-good", nonce }),
bad: mintActionToken({ userId: USER, action: "mood-bad", nonce }),
};
}
beforeEach(async () => {
vi.stubEnv(
"API_TOKEN_HMAC_KEY",
"integration-hmac-key-at-least-32-characters",
);
await truncateAllTables(getPrismaClient());
await getPrismaClient().user.create({
data: {
id: USER,
username: "notification-action",
email: "notification-action@example.test",
timezone: "Europe/Berlin",
},
});
});
describe("POST /api/notifications/action/{token}", () => {
it("logs the first answer once and refuses every later answer on the reminder", async () => {
const { good, bad } = await tokens("reminder-1");
const first = await call(good);
expect(first.status).toBe(200);
const repeat = await call(good);
expect(repeat.status).toBe(409);
const other = await call(bad);
expect(other.status).toBe(409);
const rows = await getPrismaClient().moodEntry.findMany({
where: { userId: USER },
select: { source: true, externalId: true, score: true, mood: true },
});
expect(rows).toEqual([
{
source: "NOTIFICATION",
externalId: "notification-action:reminder-1",
score: 4,
mood: "GUT",
},
]);
});
it("lets only one of two racing answers write", async () => {
const { good, bad } = await tokens("reminder-2");
const statuses = (await Promise.all([call(good), call(bad)]))
.map((res) => res.status)
.sort();
expect(statuses).toEqual([200, 409]);
expect(
await getPrismaClient().moodEntry.count({ where: { userId: USER } }),
).toBe(1);
});
it("answers a separate reminder with its own entry", async () => {
const one = await tokens("reminder-3");
const two = await tokens("reminder-4");
expect((await call(one.good)).status).toBe(200);
expect((await call(two.bad)).status).toBe(200);
expect(
await getPrismaClient().moodEntry.count({ where: { userId: USER } }),
).toBe(2);
});
it("refuses the reminder again after its entry was deleted and purged", async () => {
const { good, bad } = await tokens("reminder-purged");
expect((await call(good)).status).toBe(200);
// Delete and purge inside the token's lifetime: the row that held the
// unique key is gone.
await getPrismaClient().moodEntry.deleteMany({ where: { userId: USER } });
expect((await call(good)).status).toBe(409);
expect((await call(bad)).status).toBe(409);
expect(
await getPrismaClient().moodEntry.count({ where: { userId: USER } }),
).toBe(0);
});
});
/**
* #1203 — the request in ntfy's own shape, from the published message to the
* row. The action is taken from the JSON body `buildNtfyMessage` publishes,
* and sent the way ntfy's Android app sends an `http` action: POST, empty
* body, no cookie, no Origin, an okhttp user agent. It goes through the real
* proxy first and then the real route, with the route param taken from the
* URL path the way Next derives it.
*/
describe("a ntfy http action through the proxy and the route", () => {
async function ntfyAction() {
const { buildMoodQuickActions } =
await import("@/lib/notifications/action-token");
const { buildNtfyMessage } =
await import("@/lib/notifications/senders/ntfy");
const quickActions = buildMoodQuickActions({
userId: USER,
origin: "https://health.example.test",
labels: { "mood-good": "Good", "mood-okay": "Okay", "mood-bad": "Bad" },
});
const message = buildNtfyMessage("topic", {
userId: USER,
eventType: "MOOD_REMINDER",
title: "Mood",
message: "How are you?",
quickActions,
} as Parameters<typeof buildNtfyMessage>[1]);
// Round-trip through JSON: what the phone holds is the parsed publish body.
const published = JSON.parse(JSON.stringify(message)) as typeof message;
const okay = published.actions?.find((a) => a.label === "Okay");
if (!okay) throw new Error("no Okay action in the published message");
return okay;
}
async function send(method: string, url: string) {
const { proxy } = await import("@/proxy");
const request = new NextRequest(url, {
method,
headers: { "user-agent": "okhttp/4.12.0", "content-length": "0" },
...(method === "POST" ? { body: "" } : {}),
});
const edge = proxy(request);
if (edge.headers.get("x-middleware-next") !== "1") return edge;
const token = decodeURIComponent(
new URL(url).pathname.split("/").pop() ?? "",
);
const { POST } =
await import("@/app/api/notifications/action/[token]/route");
return POST(request, { params: Promise.resolve({ token }) });
}
it("logs the mood when the phone POSTs the published action", async () => {
const action = await ntfyAction();
expect(action).toMatchObject({ action: "http", method: "POST" });
const res = await send(action.method, action.url);
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({
data: { logged: true, action: "mood-okay" },
});
const rows = await getPrismaClient().moodEntry.findMany({
where: { userId: USER },
select: { source: true, score: true },
});
expect(rows).toEqual([{ source: "NOTIFICATION", score: 3 }]);
});
it("refuses a GET (a POST a redirect rewrote) at the proxy and writes nothing", async () => {
const action = await ntfyAction();
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const res = await send("GET", action.url);
expect(res.status).toBe(405);
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0][0])).toContain("method_not_allowed");
} finally {
warn.mockRestore();
}
expect(
await getPrismaClient().moodEntry.count({ where: { userId: USER } }),
).toBe(0);
});
});