fix(mood): assert tag-link entry ownership and guard mood migrations with IF NOT EXISTS

Thread the acting user id through createTagLinks, replaceTagLinks, and
replaceRatedFactorLinks, and assert the target MoodEntry belongs to that
user before writing any link. The check runs against the same client (tx
or singleton) as the write, so it shares one snapshot. Behaviour is
unchanged for the existing POST and bulk paths, where the entry is freshly
created and owned; the guard closes a latent cross-user link hazard for
the planned PATCH mood-edit route. A mismatched or missing entry throws
MoodEntryOwnershipError before any catalog or join touch.

Add IF NOT EXISTS to every ADD COLUMN in migration 0119 so a clean replay
is idempotent, matching the 0117 convention.

(cherry picked from commit 1bf983f6c2c5e44796c99983d57c1b1d90cd7099)
This commit is contained in:
Marc-André Bombeck committed 2026-06-05 01:05:47 +02:00
1 parent cfb97593a3
commit e77e8ae705
8 files changed
+110 -11

No files matched your search

@@ -16,13 +16,13 @@
-- alone.
-- ─── Discriminator + scale columns on the catalog tag ────────────────
ALTER TABLE "mood_tags" ADD COLUMN "kind" TEXT NOT NULL DEFAULT 'BINARY';
ALTER TABLE "mood_tags" ADD COLUMN "scale_min" INTEGER NOT NULL DEFAULT 1;
ALTER TABLE "mood_tags" ADD COLUMN "scale_max" INTEGER NOT NULL DEFAULT 5;
ALTER TABLE "mood_tags" ADD COLUMN "inverse" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "mood_tags" ADD COLUMN IF NOT EXISTS "kind" TEXT NOT NULL DEFAULT 'BINARY';
ALTER TABLE "mood_tags" ADD COLUMN IF NOT EXISTS "scale_min" INTEGER NOT NULL DEFAULT 1;
ALTER TABLE "mood_tags" ADD COLUMN IF NOT EXISTS "scale_max" INTEGER NOT NULL DEFAULT 5;
ALTER TABLE "mood_tags" ADD COLUMN IF NOT EXISTS "inverse" BOOLEAN NOT NULL DEFAULT false;
-- ─── Per-entry rating on the join ────────────────────────────────────
ALTER TABLE "mood_entry_tag_links" ADD COLUMN "rating" INTEGER;
ALTER TABLE "mood_entry_tag_links" ADD COLUMN IF NOT EXISTS "rating" INTEGER;
-- ─── New category: factors ───────────────────────────────────────────
INSERT INTO "mood_tag_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active")
+1 -1
View File
@@ -150,7 +150,7 @@ export const PUT = apiHandler(
// is present in the body. `null` clears every link; an omitted
// field leaves the links untouched.
if (data.tagKeys !== undefined) {
await replaceTagLinks(id, data.tagKeys ?? [], tx);
await replaceTagLinks(id, user.id, data.tagKeys ?? [], tx);
}
// v1.8.5 — read the persisted link keys back so the update
@@ -251,6 +251,7 @@ describe("POST /api/mood-entries — entry + tag-links transaction (v1.8.5)", ()
// arg is the rated-factor set (empty here — binary tag only).
expect(createTagLinks).toHaveBeenCalledWith(
"mood-1",
"user-1",
["happy"],
txClient,
[],
@@ -331,6 +332,7 @@ describe("POST /api/mood-entries — rated factors (v1.12.0)", () => {
expect(res.status).toBe(201);
expect(createTagLinks).toHaveBeenCalledWith(
"mood-rf",
"user-1",
["happy"],
txClient,
[{ key: "factor_work", rating: 4 }],
@@ -207,6 +207,7 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => {
// outside the upsert tx) and the rated-factor set (4th, empty here).
expect(createTagLinks).toHaveBeenCalledWith(
"entry-1",
"user-1",
["movies", "gaming"],
prisma,
[],
@@ -257,6 +258,7 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => {
expect(json.data.inserted).toBe(1);
expect(createTagLinks).toHaveBeenCalledWith(
"entry-1",
"user-1",
["movies"],
prisma,
[{ key: "factor_work", rating: 4 }],
+1
View File
@@ -249,6 +249,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
) {
await createTagLinks(
result.id,
user.id,
entry.tagKeys ?? [],
prisma,
entry.ratedFactors ?? [],
+1
View File
@@ -214,6 +214,7 @@ async function postMoodEntry(request: NextRequest) {
// throws `RatedFactorOutOfRangeError`, rolling the tx back.
await createTagLinks(
created.id,
user.id,
tagKeys ?? [],
tx,
ratedFactors ?? [],
+51 -3
View File
@@ -10,23 +10,34 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
const moodTagFindMany = vi.fn();
const linkCreateMany = vi.fn().mockResolvedValue({ count: 0 });
const linkDeleteMany = vi.fn().mockResolvedValue({ count: 0 });
const moodEntryFindUnique = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
moodEntry: { findUnique: (...a: unknown[]) => moodEntryFindUnique(...a) },
moodTag: { findMany: (...a: unknown[]) => moodTagFindMany(...a) },
moodEntryTagLink: { createMany: (...a: unknown[]) => linkCreateMany(...a) },
moodEntryTagLink: {
createMany: (...a: unknown[]) => linkCreateMany(...a),
deleteMany: (...a: unknown[]) => linkDeleteMany(...a),
},
},
}));
import {
resolveRatedFactors,
createTagLinks,
replaceRatedFactorLinks,
RatedFactorOutOfRangeError,
MoodEntryOwnershipError,
} from "@/lib/mood/tag-links";
beforeEach(() => {
moodTagFindMany.mockReset();
linkCreateMany.mockReset().mockResolvedValue({ count: 0 });
linkDeleteMany.mockReset().mockResolvedValue({ count: 0 });
// Default: the acting user owns the entry under test.
moodEntryFindUnique.mockReset().mockResolvedValue({ userId: "user-1" });
});
describe("resolveRatedFactors", () => {
@@ -81,7 +92,7 @@ describe("createTagLinks with rated factors", () => {
moodTagFindMany.mockResolvedValue([
{ id: "mt_factor_work", key: "factor_work", scaleMin: 1, scaleMax: 5 },
]);
await createTagLinks("entry-1", [], undefined, [
await createTagLinks("entry-1", "user-1", [], undefined, [
{ key: "factor_work", rating: 5 },
]);
expect(linkCreateMany).toHaveBeenCalledWith(
@@ -101,10 +112,47 @@ describe("createTagLinks with rated factors", () => {
},
]);
await expect(
createTagLinks("entry-1", [], undefined, [
createTagLinks("entry-1", "user-1", [], undefined, [
{ key: "factor_conflict", rating: 5 },
]),
).rejects.toBeInstanceOf(RatedFactorOutOfRangeError);
expect(linkCreateMany).not.toHaveBeenCalled();
});
});
describe("entry-ownership guard", () => {
it("createTagLinks throws and writes nothing when the entry is owned by another user", async () => {
moodEntryFindUnique.mockResolvedValue({ userId: "attacker" });
await expect(
createTagLinks("entry-1", "user-1", ["happy"], undefined, []),
).rejects.toBeInstanceOf(MoodEntryOwnershipError);
expect(moodTagFindMany).not.toHaveBeenCalled();
expect(linkCreateMany).not.toHaveBeenCalled();
});
it("createTagLinks throws when the entry does not exist", async () => {
moodEntryFindUnique.mockResolvedValue(null);
await expect(
createTagLinks("missing", "user-1", ["happy"], undefined, []),
).rejects.toBeInstanceOf(MoodEntryOwnershipError);
expect(linkCreateMany).not.toHaveBeenCalled();
});
it("replaceRatedFactorLinks throws and touches no links for a foreign entry", async () => {
moodEntryFindUnique.mockResolvedValue({ userId: "attacker" });
await expect(
replaceRatedFactorLinks("entry-1", "user-1", []),
).rejects.toBeInstanceOf(MoodEntryOwnershipError);
expect(linkDeleteMany).not.toHaveBeenCalled();
expect(linkCreateMany).not.toHaveBeenCalled();
});
it("createTagLinks proceeds for an owned entry", async () => {
moodTagFindMany.mockResolvedValue([]);
await createTagLinks("entry-1", "user-1", [], undefined, []);
expect(moodEntryFindUnique).toHaveBeenCalledWith({
where: { id: "entry-1" },
select: { userId: true },
});
});
});
+47 -2
View File
@@ -20,6 +20,39 @@ import { prisma } from "@/lib/db";
*/
type TagLinkDb = PrismaClient | Prisma.TransactionClient;
/**
* Raised when the acting user does not own the target mood entry. The
* link helpers refuse to touch links for an entry the session does not
* own — a structural guard so an edit route can never write a link into
* another user's entry by passing an attacker-supplied id.
*/
export class MoodEntryOwnershipError extends Error {
constructor(public readonly moodEntryId: string) {
super(`Mood entry ${moodEntryId} is not owned by the acting user`);
this.name = "MoodEntryOwnershipError";
}
}
/**
* Assert the target mood entry belongs to `userId`. Throws
* `MoodEntryOwnershipError` if it does not exist or is owned by someone
* else. Runs against the same client (tx or singleton) as the link write
* so the check and the write see one consistent snapshot.
*/
async function assertEntryOwnership(
moodEntryId: string,
userId: string,
db: TagLinkDb,
): Promise<void> {
const owner = await db.moodEntry.findUnique({
where: { id: moodEntryId },
select: { userId: true },
});
if (!owner || owner.userId !== userId) {
throw new MoodEntryOwnershipError(moodEntryId);
}
}
/**
* v1.12.0 — a rated factor as it arrives on the wire: a catalog
* (`mood_tags.key`) + the user's score for this entry.
@@ -125,14 +158,18 @@ export async function resolveTagKeysToIds(
* `keys` and `ratedFactors` resolves to a single link carrying the
* rating (the rated insert wins via `skipDuplicates`, which is why the
* rated rows are written first). Throws `RatedFactorOutOfRangeError` (→
* route 422) when a rating is out of the factor's scale.
* route 422) when a rating is out of the factor's scale. Asserts the
* entry belongs to `userId` before any write — a defensive guard against
* linking into an entry the acting session does not own.
*/
export async function createTagLinks(
moodEntryId: string,
userId: string,
keys: string[],
db: TagLinkDb = prisma,
ratedFactors: RatedFactorInput[] = [],
): Promise<void> {
await assertEntryOwnership(moodEntryId, userId, db);
// Resolve rated factors FIRST so an out-of-range rating aborts before
// any write, and so the rated rows (which carry a value) take priority
// over a bare binary row for the same tag id under `skipDuplicates`.
@@ -167,13 +204,17 @@ export async function createTagLinks(
* Replace the full structured-tag link set for an entry. `keys` is the
* desired set; the helper deletes links no longer present and inserts
* the new ones, leaving unchanged links in place. Passing an empty array
* clears every link.
* clears every link. Asserts the entry belongs to `userId` before
* touching links — a defensive guard against editing links on an entry
* the acting session does not own.
*/
export async function replaceTagLinks(
moodEntryId: string,
userId: string,
keys: string[],
db: TagLinkDb = prisma,
): Promise<void> {
await assertEntryOwnership(moodEntryId, userId, db);
const desiredIds = new Set(await resolveTagKeysToIds(keys, db));
const existing = await db.moodEntryTagLink.findMany({
where: { moodEntryId },
@@ -207,12 +248,16 @@ export async function replaceTagLinks(
*
* Not wired into a route yet (the POST + bulk ingestion contract is the
* v1.12.0 scope); exported for the PATCH edit path a later wave adds.
* Asserts the entry belongs to `userId` before any write — a defensive
* guard for that future edit route against touching another user's entry.
*/
export async function replaceRatedFactorLinks(
moodEntryId: string,
userId: string,
factors: RatedFactorInput[],
db: TagLinkDb = prisma,
): Promise<void> {
await assertEntryOwnership(moodEntryId, userId, db);
// Resolve (and range-check) before mutating so an out-of-range rating
// aborts the replace cleanly.
const resolved = await resolveRatedFactors(factors, db);