test(invites): document empty-updates guard + Path A rollback test
Quality-review polish: a one-line comment over the empty-updates guard in reinstateInvite explains why removing it would re-leak a confusing Drizzle error. A new test verifies that when Path A (revoked->active) fails its post-state check, the original token is preserved by the SQLite transaction rollback (not replaced by the would-be new token).
This commit is contained in:
@@ -445,4 +445,22 @@ describe('reinstateInvite', () => {
|
|||||||
const row = testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, inv.id)).get();
|
const row = testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, inv.id)).get();
|
||||||
expect(row?.maxUses).toBe(1);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -441,6 +441,12 @@ export function reinstateInvite(id: string, req: ReinstateInviteRequest): Reinst
|
|||||||
updates.expiresAt = validateExpiresAt(req.expiresAt, true);
|
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) {
|
if (Object.keys(updates).length > 0) {
|
||||||
tx.update(schema.inviteLinks).set(updates).where(eq(schema.inviteLinks.id, id)).run();
|
tx.update(schema.inviteLinks).set(updates).where(eq(schema.inviteLinks.id, id)).run();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user