diff --git a/packages/server/src/utils/inviteService.test.ts b/packages/server/src/utils/inviteService.test.ts index 4cd0e501..4ec5e7da 100644 --- a/packages/server/src/utils/inviteService.test.ts +++ b/packages/server/src/utils/inviteService.test.ts @@ -445,4 +445,22 @@ describe('reinstateInvite', () => { const row = testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, inv.id)).get(); expect(row?.maxUses).toBe(1); }); + + it('Path A — rolls back token rotation when caller did not bump enough', () => { + const adminId = seedAdmin(); + const inv = createInvite({ name: 'a', maxUses: 1, expiresAt: null }, adminId); + const originalToken = inv.token; + // Exhaust then revoke + testDb.update(schema.inviteLinks).set({ usedCount: 1 }).where(eq(schema.inviteLinks.id, inv.id)).run(); + revokeInvite(inv.id); + + // Try to reinstate without bumping maxUses — would-be Path A but post-state check rejects + expect(() => reinstateInvite(inv.id, {})).toThrow(InviteValidationError); + + // Verify rollback: original token preserved, revokedAt still set, usedCount still at limit + const row = testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, inv.id)).get(); + expect(row?.token).toBe(originalToken); + expect(row?.revokedAt).not.toBeNull(); + expect(row?.usedCount).toBe(1); + }); }); diff --git a/packages/server/src/utils/inviteService.ts b/packages/server/src/utils/inviteService.ts index d318227f..c068f795 100644 --- a/packages/server/src/utils/inviteService.ts +++ b/packages/server/src/utils/inviteService.ts @@ -441,6 +441,12 @@ export function reinstateInvite(id: string, req: ReinstateInviteRequest): Reinst updates.expiresAt = validateExpiresAt(req.expiresAt, true); } + // Skip the UPDATE entirely when there's nothing to set — Drizzle throws + // 'No values to set' before our post-state validator can produce the + // user-facing InviteValidationError. Path C (already active) handles its + // rejection above, so an empty updates map only reaches here when the + // caller didn't provide bumps for an expired/exhausted invite — the + // post-state check below will throw the correct error in that case. if (Object.keys(updates).length > 0) { tx.update(schema.inviteLinks).set(updates).where(eq(schema.inviteLinks.id, id)).run(); }