mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
fix(notifications): keep a quick answer single-use after its entry is purged and document the topic exposure
This commit is contained in:
1 parent
4ff9aa7e39
commit
5dbc6fcec1
5 files changed
+183
-15
No files matched your search
@@ -10,7 +10,10 @@ 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() } },
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
idempotencyKey: { create: vi.fn(), deleteMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
@@ -67,6 +70,10 @@ beforeEach(() => {
|
||||
timezone: "Europe/Berlin",
|
||||
} as never);
|
||||
vi.mocked(isModuleEnabled).mockResolvedValue(true);
|
||||
vi.mocked(prisma.idempotencyKey.create).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.idempotencyKey.deleteMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(logTelegramMood).mockResolvedValue({
|
||||
created: true,
|
||||
moodEntryId: "m1",
|
||||
@@ -151,4 +158,42 @@ describe("POST /api/notifications/action/{token}", () => {
|
||||
const res = await call(token({ action: "mood-bad" }));
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("refuses a spent nonce with 409 before writing anything", async () => {
|
||||
const { Prisma } = await import("@/generated/prisma/client");
|
||||
vi.mocked(prisma.idempotencyKey.create).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("unique", {
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
}),
|
||||
);
|
||||
const res = await call(token());
|
||||
expect(res.status).toBe(409);
|
||||
expect(logTelegramMood).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("claims the nonce until the token expires", async () => {
|
||||
const now = Date.now();
|
||||
await call(token({ now }));
|
||||
expect(prisma.idempotencyKey.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
userId: "user-1",
|
||||
key: "notification-action:nonce-1",
|
||||
requestFingerprint: null,
|
||||
expiresAt: new Date(now + ACTION_TOKEN_TTL_MS),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("gives the claim back when the write fails for another reason", async () => {
|
||||
vi.mocked(logTelegramMood).mockRejectedValue(new Error("db down"));
|
||||
const res = await call(token());
|
||||
expect(res.status).toBe(500);
|
||||
expect(prisma.idempotencyKey.deleteMany).toHaveBeenCalledWith({
|
||||
where: expect.objectContaining({
|
||||
userId: "user-1",
|
||||
key: "notification-action:nonce-1",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,10 +13,12 @@
|
||||
* 2. Verify the signature (constant-time) and the expiry.
|
||||
* 3. Re-check what may have changed since the reminder went out: the
|
||||
* account still exists and its mood module is still on.
|
||||
* 4. Write the mood entry under the reminder's nonce. The unique
|
||||
* `(userId, source, externalId)` key makes the first answer the only
|
||||
* 4. Claim the reminder's nonce (`action-nonce.ts`), then write the mood
|
||||
* entry under it. The claim and the entry's unique
|
||||
* `(userId, source, externalId)` key each make the first answer the only
|
||||
* one: a repeat tap, or a different answer on the same reminder, is
|
||||
* refused with 409 and writes nothing.
|
||||
* refused with 409 and writes nothing. The claim is what still holds
|
||||
* after the entry was deleted and purged inside the token's lifetime.
|
||||
*
|
||||
* The token travels in the path, so it is scrubbed from every log line by
|
||||
* the `PATH_SECRET_PATHS` entry for this prefix, and the route sits on the
|
||||
@@ -31,6 +33,10 @@ import { prisma } from "@/lib/db";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { logTelegramMood } from "@/lib/mood/create-from-telegram";
|
||||
import {
|
||||
claimActionNonce,
|
||||
releaseActionNonce,
|
||||
} from "@/lib/notifications/action-nonce";
|
||||
import {
|
||||
MOOD_QUICK_ACTIONS,
|
||||
quickActionExternalId,
|
||||
@@ -116,6 +122,23 @@ export const POST = apiHandler(
|
||||
});
|
||||
}
|
||||
|
||||
const alreadyUsed = () => {
|
||||
annotate({
|
||||
action: { name: "notification.action.refused" },
|
||||
meta: { reason: "already_used" },
|
||||
});
|
||||
return apiError("This reminder was already answered", 409, {
|
||||
errorCode: "notification.action.already_used",
|
||||
});
|
||||
};
|
||||
|
||||
const claimed = await claimActionNonce({
|
||||
userId: user.id,
|
||||
nonce: verdict.nonce,
|
||||
expiry: verdict.expiry,
|
||||
});
|
||||
if (!claimed) return alreadyUsed();
|
||||
|
||||
const score = MOOD_QUICK_ACTIONS[verdict.action];
|
||||
let created: boolean;
|
||||
try {
|
||||
@@ -129,19 +152,15 @@ export const POST = apiHandler(
|
||||
created = result.created;
|
||||
} catch (err) {
|
||||
// Two taps raced past the existence check; the unique key let one in.
|
||||
if (!isUniqueViolation(err)) throw err;
|
||||
if (!isUniqueViolation(err)) {
|
||||
// Nothing was written: give the answer back so a retry can land.
|
||||
await releaseActionNonce({ userId: user.id, nonce: verdict.nonce });
|
||||
throw err;
|
||||
}
|
||||
created = false;
|
||||
}
|
||||
|
||||
if (!created) {
|
||||
annotate({
|
||||
action: { name: "notification.action.refused" },
|
||||
meta: { reason: "already_used" },
|
||||
});
|
||||
return apiError("This reminder was already answered", 409, {
|
||||
errorCode: "notification.action.already_used",
|
||||
});
|
||||
}
|
||||
if (!created) return alreadyUsed();
|
||||
|
||||
annotate({
|
||||
action: { name: "notification.action.mood_logged" },
|
||||
|
||||
Reference in new issue
Block a user