From f0afa4d880d7e97814eeb44d8bc6fef2d3101df2 Mon Sep 17 00:00:00 2001 From: Hari Om Date: Fri, 5 Jun 2026 15:05:01 +0530 Subject: [PATCH 1/7] 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/7] 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 From d195631aad47cb44ab7aaa537a35b70fcba60d0f Mon Sep 17 00:00:00 2001 From: Hari Om Date: Thu, 11 Jun 2026 17:26:58 +0530 Subject: [PATCH 3/7] fix(cardService): throw errors in deleteCard and export CardResponse type --- apps/backend/src/services/cardService.ts | 35 +++++++++++++++++------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/apps/backend/src/services/cardService.ts b/apps/backend/src/services/cardService.ts index 8156a4f4..56fcdef8 100644 --- a/apps/backend/src/services/cardService.ts +++ b/apps/backend/src/services/cardService.ts @@ -1,13 +1,26 @@ -import type { Prisma } from '@prisma/client' -import type { FastifyInstance } from 'fastify' +import type { Prisma } from '@prisma/client'; +import type { FastifyInstance } from 'fastify'; + +type CardLinkResponse = { platformLink: unknown }; +type RawCard = { id: string; title: string; isDefault: boolean; cardLinks: CardLinkResponse[] }; +export type CardResponse = { id: string; title: string; isDefault: boolean; links: unknown[] }; + +function mapCard(card: RawCard): CardResponse { + return { + id: card.id, + title: card.title, + isDefault: card.isDefault, + links: card.cardLinks.map((cardLink) => cardLink.platformLink), + }; +} -export async function listCards(app: FastifyInstance, userId: string): Promise { - const cards = (await app.prisma.card.findMany({ +export async function listCards(app: FastifyInstance, userId: string) { + const cards = await app.prisma.card.findMany({ where: { userId }, take: 50, include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, orderBy: { createdAt: 'asc' }, - })) as unknown as RawCard[]; + }) as unknown as RawCard[]; return cards.map(mapCard); } @@ -101,23 +114,25 @@ export async function updateCard(app: FastifyInstance, userId: string, id: strin } }) - const updated = await app.prisma.card.findUnique({ + 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) } + })) as unknown as RawCard | null; + + if (!updated) return null; + return mapCard(updated); } export async function deleteCard(app: FastifyInstance, userId: string, id: string): Promise { 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' }) + throw 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' }) + throw Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' }) } if (existing.isDefault) { From 9e0c28a9882c310e5573a4d70915e5778b33ec1b Mon Sep 17 00:00:00 2001 From: Hari Om Date: Thu, 11 Jun 2026 19:49:23 +0530 Subject: [PATCH 4/7] fixed --- apps/backend/src/__tests__/cards.test.ts | 7 +++++++ apps/backend/src/services/cardService.ts | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/__tests__/cards.test.ts b/apps/backend/src/__tests__/cards.test.ts index 28ba0b3c..62448c83 100644 --- a/apps/backend/src/__tests__/cards.test.ts +++ b/apps/backend/src/__tests__/cards.test.ts @@ -440,6 +440,7 @@ describe('PUT /api/cards/:id/default', () => { expect(mockPrisma.card.updateMany).toHaveBeenCalled(); expect(mockPrisma.card.update).toHaveBeenCalled(); }); +<<<<<<< Updated upstream }); // ───────────────────────────────────────────────────────────────────────────── @@ -558,3 +559,9 @@ describe('PUT /api/cards/:id — atomicity of combined title + linkIds update', expect(mockPrisma.cardLink.createMany).toHaveBeenCalled() }) }) +<<<<<<< HEAD +======= +======= +}); +>>>>>>> Stashed changes +>>>>>>> 3d9c705 (fixed) diff --git a/apps/backend/src/services/cardService.ts b/apps/backend/src/services/cardService.ts index 56fcdef8..04a38bce 100644 --- a/apps/backend/src/services/cardService.ts +++ b/apps/backend/src/services/cardService.ts @@ -14,8 +14,8 @@ function mapCard(card: RawCard): CardResponse { }; } -export async function listCards(app: FastifyInstance, userId: string) { - const cards = await app.prisma.card.findMany({ +export async function listCards(app: FastifyInstance, userId: string): Promise { + const cards = (await app.prisma.card.findMany({ where: { userId }, take: 50, include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, From 23462b09e0a97abcfa51e8ef4dc3e21cd0e05bb0 Mon Sep 17 00:00:00 2001 From: Hari Om Date: Thu, 11 Jun 2026 22:46:58 +0530 Subject: [PATCH 5/7] done --- apps/backend/src/__tests__/cards.test.ts | 9 +-------- apps/backend/src/services/cardService.ts | 2 +- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/apps/backend/src/__tests__/cards.test.ts b/apps/backend/src/__tests__/cards.test.ts index 62448c83..8a295e15 100644 --- a/apps/backend/src/__tests__/cards.test.ts +++ b/apps/backend/src/__tests__/cards.test.ts @@ -440,7 +440,6 @@ describe('PUT /api/cards/:id/default', () => { expect(mockPrisma.card.updateMany).toHaveBeenCalled(); expect(mockPrisma.card.update).toHaveBeenCalled(); }); -<<<<<<< Updated upstream }); // ───────────────────────────────────────────────────────────────────────────── @@ -558,10 +557,4 @@ describe('PUT /api/cards/:id — atomicity of combined title + linkIds update', expect(mockPrisma.cardLink.deleteMany).toHaveBeenCalledWith({ where: { cardId: CARD_ID } }) expect(mockPrisma.cardLink.createMany).toHaveBeenCalled() }) -}) -<<<<<<< HEAD -======= -======= -}); ->>>>>>> Stashed changes ->>>>>>> 3d9c705 (fixed) +}) \ No newline at end of file diff --git a/apps/backend/src/services/cardService.ts b/apps/backend/src/services/cardService.ts index 04a38bce..386d8c7e 100644 --- a/apps/backend/src/services/cardService.ts +++ b/apps/backend/src/services/cardService.ts @@ -20,7 +20,7 @@ export async function listCards(app: FastifyInstance, userId: string): Promise Date: Thu, 11 Jun 2026 23:00:02 +0530 Subject: [PATCH 6/7] fixed lint check --- apps/backend/src/services/cardService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/services/cardService.ts b/apps/backend/src/services/cardService.ts index 386d8c7e..7090193f 100644 --- a/apps/backend/src/services/cardService.ts +++ b/apps/backend/src/services/cardService.ts @@ -77,7 +77,7 @@ export async function createCard(app: FastifyInstance, userId: string, body: { t throw new Error('Failed to create card after retrying serialization conflicts'); } -export async function updateCard(app: FastifyInstance, userId: string, id: string, body: { title?: string; linkIds?: string[] }) { +export async function updateCard(app: FastifyInstance, userId: string, id: string, body: { title?: string; linkIds?: string[] }): Promise { const existing = await app.prisma.card.findFirst({ where: { id, userId } }) if (!existing) { return null @@ -151,7 +151,7 @@ export async function deleteCard(app: FastifyInstance, userId: string, id: strin }); } -export async function setDefaultCard(app: FastifyInstance, userId: string, id: string) { +export async function setDefaultCard(app: FastifyInstance, userId: string, id: string): Promise<{ message: string } | null> { const existing = await app.prisma.card.findFirst({ where: { id, userId } }) if (!existing) { return null From 1e814b3fb437aa6f0d44f13729fe2483fcd638bb Mon Sep 17 00:00:00 2001 From: Hari Om Date: Thu, 11 Jun 2026 23:20:37 +0530 Subject: [PATCH 7/7] final fix --- apps/backend/src/services/cardService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/services/cardService.ts b/apps/backend/src/services/cardService.ts index 7090193f..3dcd87ef 100644 --- a/apps/backend/src/services/cardService.ts +++ b/apps/backend/src/services/cardService.ts @@ -119,7 +119,7 @@ export async function updateCard(app: FastifyInstance, userId: string, id: strin include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, })) as unknown as RawCard | null; - if (!updated) return null; + if (!updated) {return null;} return mapCard(updated); }