Commit
Make share tokens race safe
commit
db0db1c
2 changed files with +58 and −4
Jump to a changed file
- src/db/repositories.test.ts +46 −0
- src/db/repositories.ts +12 −4
added src/db/repositories.test.ts +46 −0
| @@ -0,0 +1,46 @@ | ||
| 1 | +import { beforeEach, describe, expect, it, vi } from 'vitest'; | |
| 2 | + | |
| 3 | +const prisma = vi.hoisted(() => ({ | |
| 4 | + debate: { | |
| 5 | + updateMany: vi.fn(), | |
| 6 | + findUnique: vi.fn(), | |
| 7 | + }, | |
| 8 | +})); | |
| 9 | + | |
| 10 | +vi.mock('./client', () => ({ prisma })); | |
| 11 | + | |
| 12 | +import { ensureShareToken } from './repositories'; | |
| 13 | + | |
| 14 | +describe('ensureShareToken', () => { | |
| 15 | + beforeEach(() => { | |
| 16 | + prisma.debate.updateMany.mockReset(); | |
| 17 | + prisma.debate.findUnique.mockReset(); | |
| 18 | + }); | |
| 19 | + | |
| 20 | + it('claims an empty share token without a preliminary read', async () => { | |
| 21 | + prisma.debate.updateMany.mockResolvedValue({ count: 1 }); | |
| 22 | + | |
| 23 | + const token = await ensureShareToken('debate-1'); | |
| 24 | + | |
| 25 | + expect(token).toMatch(/^[A-Za-z0-9_-]{24}$/); | |
| 26 | + expect(prisma.debate.updateMany).toHaveBeenCalledWith({ | |
| 27 | + where: { id: 'debate-1', shareToken: null }, | |
| 28 | + data: { shareToken: token }, | |
| 29 | + }); | |
| 30 | + expect(prisma.debate.findUnique).not.toHaveBeenCalled(); | |
| 31 | + }); | |
| 32 | + | |
| 33 | + it('returns the token claimed by a concurrent request', async () => { | |
| 34 | + prisma.debate.updateMany.mockResolvedValue({ count: 0 }); | |
| 35 | + prisma.debate.findUnique.mockResolvedValue({ shareToken: 'existing-token' }); | |
| 36 | + | |
| 37 | + await expect(ensureShareToken('debate-1')).resolves.toBe('existing-token'); | |
| 38 | + }); | |
| 39 | + | |
| 40 | + it('fails when the debate no longer exists', async () => { | |
| 41 | + prisma.debate.updateMany.mockResolvedValue({ count: 0 }); | |
| 42 | + prisma.debate.findUnique.mockResolvedValue(null); | |
| 43 | + | |
| 44 | + await expect(ensureShareToken('missing')).rejects.toThrow('Debate not found'); | |
| 45 | + }); | |
| 46 | +}); |
modified src/db/repositories.ts +12 −4
| @@ -488,11 +488,19 @@export async function countRecentDebates(userId: string, sinceMs: number): Promi | ||
| 488 | 488 | // --------------------------------------------------------------------------- |
| 489 | 489 | |
| 490 | 490 | export async function ensureShareToken(debateId: string): Promise<string> { |
| 491 | - const existing = await prisma.debate.findUnique({ where: { id: debateId }, select: { shareToken: true } }); | |
| 492 | - if (existing?.shareToken) return existing.shareToken; | |
| 493 | 491 | const token = randomToken(); |
| 494 | - await prisma.debate.update({ where: { id: debateId }, data: { shareToken: token } }); | |
| 495 | - return token; | |
| 492 | + const claimed = await prisma.debate.updateMany({ | |
| 493 | + where: { id: debateId, shareToken: null }, | |
| 494 | + data: { shareToken: token }, | |
| 495 | + }); | |
| 496 | + if (claimed.count === 1) return token; | |
| 497 | + | |
| 498 | + const existing = await prisma.debate.findUnique({ | |
| 499 | + where: { id: debateId }, | |
| 500 | + select: { shareToken: true }, | |
| 501 | + }); | |
| 502 | + if (!existing?.shareToken) throw new Error('Debate not found'); | |
| 503 | + return existing.shareToken; | |
| 496 | 504 | } |
| 497 | 505 | |
| 498 | 506 | // --------------------------------------------------------------------------- |