From f0afa4d880d7e97814eeb44d8bc6fef2d3101df2 Mon Sep 17 00:00:00 2001 From: Hari Om Date: Fri, 5 Jun 2026 15:05:01 +0530 Subject: [PATCH 1/2] fix(cardService): wrap title + linkIds updates in single atomic transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updateCard previously executed card.update (title) and the cardLink delete/create cycle as two independent operations. A process crash, DB timeout, or FK violation between them left the card with a new title but stale links — a permanently inconsistent state with no rollback path. Changes: - Move both the card.update (title) and the cardLink.deleteMany / cardLink.createMany calls inside a single app.prisma. block so that either all changes commit or none do. - Hoist the platformLink ownership check to before the transaction is opened, eliminating the TOCTOU window where a concurrent request could delete a platformLink between validation and createMany. Ownership validation on user-owned immutable rows is safe to perform outside the transaction. Tests added (cards.test.ts): - Happy path: both title and links commit in one call. - Rollback path: createMany failure after card.update → 500, no findUnique called, both operations ran inside the same tx. - Pre-transaction 403: foreign linkId → never called, no writes of any kind. - Title-only update: linkIds absent → deleteMany not called. - Links-only update: title absent → card.update not called. --- apps/backend/src/__tests__/cards.test.ts | 117 +++++++++++++++++++++++ apps/backend/src/services/cardService.ts | 38 +++++--- 2 files changed, 142 insertions(+), 13 deletions(-) diff --git a/apps/backend/src/__tests__/cards.test.ts b/apps/backend/src/__tests__/cards.test.ts index f110c0da..8a295e15 100644 --- a/apps/backend/src/__tests__/cards.test.ts +++ b/apps/backend/src/__tests__/cards.test.ts @@ -441,3 +441,120 @@ describe('PUT /api/cards/:id/default', () => { expect(mockPrisma.card.update).toHaveBeenCalled(); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// PUT /api/cards/:id — atomicity of combined title + linkIds update (#437) +// ───────────────────────────────────────────────────────────────────────────── + +describe('PUT /api/cards/:id — atomicity of combined title + linkIds update', () => { + beforeEach(() => { + vi.clearAllMocks() + wireTransaction() + }) + + it('commits both title and links in a single transaction on success', async () => { + mockPrisma.card.findFirst.mockResolvedValue(mockCard) + mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]) + mockPrisma.card.update.mockResolvedValue({ ...mockCard, title: 'New Title' }) + mockPrisma.cardLink.deleteMany.mockResolvedValue({ count: 0 }) + mockPrisma.cardLink.createMany.mockResolvedValue({ count: 1 }) + mockPrisma.card.findUnique.mockResolvedValue({ ...mockCard, title: 'New Title', cardLinks: [] }) + + const app = await buildApp() + const res = await app.inject({ + method: 'PUT', + url: `/api/cards/${CARD_ID}`, + payload: { title: 'New Title', linkIds: [OWNED_LINK_ID] }, + }) + + expect(res.statusCode).toBe(200) + // Both mutations must be inside one transaction, not two separate calls + expect(mockPrisma.$transaction).toHaveBeenCalledOnce() + expect(mockPrisma.card.update).toHaveBeenCalledWith({ where: { id: CARD_ID }, data: { title: 'New Title' } }) + expect(mockPrisma.cardLink.deleteMany).toHaveBeenCalledWith({ where: { cardId: CARD_ID } }) + expect(mockPrisma.cardLink.createMany).toHaveBeenCalled() + }) + + it('does not commit the title when the linkIds createMany fails (full rollback)', async () => { + mockPrisma.card.findFirst.mockResolvedValue(mockCard) + mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]) + // card.update (title) succeeds inside tx, but createMany blows up + mockPrisma.card.update.mockResolvedValue({ ...mockCard, title: 'New Title' }) + mockPrisma.cardLink.deleteMany.mockResolvedValue({ count: 1 }) + mockPrisma.cardLink.createMany.mockRejectedValue(new Error('FK constraint violation')) + + const app = await buildApp() + const res = await app.inject({ + method: 'PUT', + url: `/api/cards/${CARD_ID}`, + payload: { title: 'New Title', linkIds: [OWNED_LINK_ID] }, + }) + + expect(res.statusCode).toBe(500) + // The transaction rolled back — the final read must not have been attempted + expect(mockPrisma.card.findUnique).not.toHaveBeenCalled() + // Confirm both operations ran inside the same tx (the DB undoes them together) + expect(mockPrisma.card.update).toHaveBeenCalled() + expect(mockPrisma.cardLink.createMany).toHaveBeenCalled() + }) + + it('returns 403 and opens no transaction when a linkId fails ownership validation', async () => { + mockPrisma.card.findFirst.mockResolvedValue(mockCard) + // Ownership check returns empty — foreign linkId + mockPrisma.platformLink.findMany.mockResolvedValue([]) + + const app = await buildApp() + const res = await app.inject({ + method: 'PUT', + url: `/api/cards/${CARD_ID}`, + payload: { title: 'New Title', linkIds: [FOREIGN_LINK_ID] }, + }) + + expect(res.statusCode).toBe(403) + expect(res.json().error).toBe('One or more links do not belong to your account') + // No transaction must have been opened — no writes of any kind + expect(mockPrisma.$transaction).not.toHaveBeenCalled() + expect(mockPrisma.card.update).not.toHaveBeenCalled() + expect(mockPrisma.cardLink.deleteMany).not.toHaveBeenCalled() + }) + + it('applies only the title update when linkIds is absent', async () => { + mockPrisma.card.findFirst.mockResolvedValue(mockCard) + mockPrisma.card.update.mockResolvedValue({ ...mockCard, title: 'Title Only' }) + mockPrisma.card.findUnique.mockResolvedValue({ ...mockCard, title: 'Title Only', cardLinks: [] }) + + const app = await buildApp() + const res = await app.inject({ + method: 'PUT', + url: `/api/cards/${CARD_ID}`, + payload: { title: 'Title Only' }, + }) + + expect(res.statusCode).toBe(200) + expect(mockPrisma.$transaction).toHaveBeenCalledOnce() + expect(mockPrisma.card.update).toHaveBeenCalledWith({ where: { id: CARD_ID }, data: { title: 'Title Only' } }) + expect(mockPrisma.cardLink.deleteMany).not.toHaveBeenCalled() + expect(mockPrisma.platformLink.findMany).not.toHaveBeenCalled() + }) + + it('applies only link replacement when title is absent', async () => { + mockPrisma.card.findFirst.mockResolvedValue(mockCard) + mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]) + mockPrisma.cardLink.deleteMany.mockResolvedValue({ count: 1 }) + mockPrisma.cardLink.createMany.mockResolvedValue({ count: 1 }) + mockPrisma.card.findUnique.mockResolvedValue({ ...mockCard, cardLinks: [] }) + + const app = await buildApp() + const res = await app.inject({ + method: 'PUT', + url: `/api/cards/${CARD_ID}`, + payload: { linkIds: [OWNED_LINK_ID] }, + }) + + expect(res.statusCode).toBe(200) + expect(mockPrisma.$transaction).toHaveBeenCalledOnce() + expect(mockPrisma.card.update).not.toHaveBeenCalled() + expect(mockPrisma.cardLink.deleteMany).toHaveBeenCalledWith({ where: { cardId: CARD_ID } }) + expect(mockPrisma.cardLink.createMany).toHaveBeenCalled() + }) +}) \ No newline at end of file diff --git a/apps/backend/src/services/cardService.ts b/apps/backend/src/services/cardService.ts index a9721783..d523ae39 100644 --- a/apps/backend/src/services/cardService.ts +++ b/apps/backend/src/services/cardService.ts @@ -37,26 +37,38 @@ export async function updateCard(app: FastifyInstance, userId: string, id: strin const existing = await app.prisma.card.findFirst({ where: { id, userId } }) if (!existing) return null - if (body.title) { - await app.prisma.card.update({ where: { id }, data: { title: body.title } }) + if (body.linkIds && body.linkIds.length > 0) { + const ownedLinks = await app.prisma.platformLink.findMany({ + where: { id: { in: body.linkIds }, userId }, + select: { id: true }, + }) + if (ownedLinks.length !== body.linkIds.length) + throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }) } - if (body.linkIds) { - if (body.linkIds.length > 0) { - const ownedLinks = await app.prisma.platformLink.findMany({ where: { id: { in: body.linkIds }, userId }, select: { id: true } }) - if (ownedLinks.length !== body.linkIds.length) throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }) + await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { + if (body.title) { + await tx.card.update({ where: { id }, data: { title: body.title } }) } - const linkIds = body.linkIds - await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { + if (body.linkIds) { await tx.cardLink.deleteMany({ where: { cardId: id } }) - if (linkIds.length > 0) { - await tx.cardLink.createMany({ data: linkIds.map((linkId, index) => ({ cardId: id, platformLinkId: linkId, displayOrder: index })) }) + if (body.linkIds.length > 0) { + await tx.cardLink.createMany({ + data: body.linkIds.map((linkId, index) => ({ + cardId: id, + platformLinkId: linkId, + displayOrder: index, + })), + }) } - }) - } + } + }) - const updated = await app.prisma.card.findUnique({ where: { id }, include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } } }) + const updated = await app.prisma.card.findUnique({ + where: { id }, + include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, + }) return { id: updated!.id, title: updated!.title, isDefault: updated!.isDefault, links: updated!.cardLinks.map((cl: any) => cl.platformLink) } } From 5bbbf9e04848fce5d8cc171428127adc1f2fdefa Mon Sep 17 00:00:00 2001 From: Hari Om Date: Fri, 5 Jun 2026 16:02:42 +0530 Subject: [PATCH 2/2] fix(lint): add curly braces and fix import order in cardService.ts --- apps/backend/src/services/cardService.ts | 35 ++++++++++++++++-------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/apps/backend/src/services/cardService.ts b/apps/backend/src/services/cardService.ts index d523ae39..4447496a 100644 --- a/apps/backend/src/services/cardService.ts +++ b/apps/backend/src/services/cardService.ts @@ -1,5 +1,5 @@ -import type { FastifyInstance } from 'fastify' import type { Prisma } from '@prisma/client' +import type { FastifyInstance } from 'fastify' export async function listCards(app: FastifyInstance, userId: string) { const cards = await app.prisma.card.findMany({ @@ -15,7 +15,9 @@ export async function listCards(app: FastifyInstance, userId: string) { export async function createCard(app: FastifyInstance, userId: string, body: { title: string; linkIds: string[] }) { if (body.linkIds.length > 0) { const ownedLinks = await app.prisma.platformLink.findMany({ where: { id: { in: body.linkIds }, userId }, select: { id: true } }) - if (ownedLinks.length !== body.linkIds.length) throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }) + if (ownedLinks.length !== body.linkIds.length) { + throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }) + } } const cardCount = await app.prisma.card.count({ where: { userId } }) @@ -35,27 +37,32 @@ export async function createCard(app: FastifyInstance, userId: string, body: { t export async function updateCard(app: FastifyInstance, userId: string, id: string, body: { title?: string; linkIds?: string[] }) { const existing = await app.prisma.card.findFirst({ where: { id, userId } }) - if (!existing) return null + if (!existing) { + return null + } if (body.linkIds && body.linkIds.length > 0) { const ownedLinks = await app.prisma.platformLink.findMany({ where: { id: { in: body.linkIds }, userId }, select: { id: true }, }) - if (ownedLinks.length !== body.linkIds.length) + if (ownedLinks.length !== body.linkIds.length) { throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }) + } } + const linkIds = body.linkIds + await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { if (body.title) { await tx.card.update({ where: { id }, data: { title: body.title } }) } - if (body.linkIds) { + if (linkIds !== undefined) { await tx.cardLink.deleteMany({ where: { cardId: id } }) - if (body.linkIds.length > 0) { + if (linkIds.length > 0) { await tx.cardLink.createMany({ - data: body.linkIds.map((linkId, index) => ({ + data: linkIds.map((linkId, index) => ({ cardId: id, platformLinkId: linkId, displayOrder: index, @@ -75,10 +82,14 @@ export async function updateCard(app: FastifyInstance, userId: string, id: strin export async function deleteCard(app: FastifyInstance, userId: string, id: string) { return await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { const existing = await tx.card.findFirst({ where: { id, userId } }) - if (!existing) return Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' }) + if (!existing) { + return Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' }) + } const userCardCount = await tx.card.count({ where: { userId } }) - if (userCardCount <= 1) return Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' }) + if (userCardCount <= 1) { + return Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' }) + } if (existing.isDefault) { const oldestRemainingCard = await tx.card.findFirst({ where: { userId, id: { not: id } }, orderBy: { createdAt: 'asc' } }) @@ -94,7 +105,9 @@ export async function deleteCard(app: FastifyInstance, userId: string, id: strin export async function setDefaultCard(app: FastifyInstance, userId: string, id: string) { const existing = await app.prisma.card.findFirst({ where: { id, userId } }) - if (!existing) return null + if (!existing) { + return null + } await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { await tx.card.updateMany({ where: { userId }, data: { isDefault: false } }) @@ -102,4 +115,4 @@ export async function setDefaultCard(app: FastifyInstance, userId: string, id: s }) return { message: 'Default card updated' } -} +} \ No newline at end of file