diff --git a/prisma/migrations/0119_v1120_mood_rated_factors/migration.sql b/prisma/migrations/0119_v1120_mood_rated_factors/migration.sql index 7ceac1cd5..65b2450e7 100644 --- a/prisma/migrations/0119_v1120_mood_rated_factors/migration.sql +++ b/prisma/migrations/0119_v1120_mood_rated_factors/migration.sql @@ -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") diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index f0e940154..23b61e8ea 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -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 diff --git a/src/app/api/mood-entries/__tests__/route.test.ts b/src/app/api/mood-entries/__tests__/route.test.ts index 69b666407..b6831c601 100644 --- a/src/app/api/mood-entries/__tests__/route.test.ts +++ b/src/app/api/mood-entries/__tests__/route.test.ts @@ -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 }], diff --git a/src/app/api/mood-entries/bulk/__tests__/route.test.ts b/src/app/api/mood-entries/bulk/__tests__/route.test.ts index 28467f62b..6e40a22ec 100644 --- a/src/app/api/mood-entries/bulk/__tests__/route.test.ts +++ b/src/app/api/mood-entries/bulk/__tests__/route.test.ts @@ -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 }], diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index 3c26b8cdb..2879727eb 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -249,6 +249,7 @@ async function postBulk(request: NextRequest): Promise { ) { await createTagLinks( result.id, + user.id, entry.tagKeys ?? [], prisma, entry.ratedFactors ?? [], diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 42a1aba11..e0e0451df 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -214,6 +214,7 @@ async function postMoodEntry(request: NextRequest) { // throws `RatedFactorOutOfRangeError`, rolling the tx back. await createTagLinks( created.id, + user.id, tagKeys ?? [], tx, ratedFactors ?? [], diff --git a/src/lib/mood/__tests__/tag-links.test.ts b/src/lib/mood/__tests__/tag-links.test.ts index f171a7091..1b8c4e3b4 100644 --- a/src/lib/mood/__tests__/tag-links.test.ts +++ b/src/lib/mood/__tests__/tag-links.test.ts @@ -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 }, + }); + }); +}); diff --git a/src/lib/mood/tag-links.ts b/src/lib/mood/tag-links.ts index 857ca0216..3e631d938 100644 --- a/src/lib/mood/tag-links.ts +++ b/src/lib/mood/tag-links.ts @@ -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 { + 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 { + 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 { + 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 { + 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);