profileShare

rasmusjy / roundtable

Read-only snapshot

No repository description.

main default branch 181 files Expires Sep 13, 2026, 9:06 AM

Commit

Make share tokens race safe

commit db0db1c

2 changed files with +58 and −4

Jump to a changed file
  1. src/db/repositories.test.ts +46 −0
  2. 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 // ---------------------------------------------------------------------------