feat(invites): redeemInvite (atomic txn) + deleteInvite
This commit is contained in:
@@ -24,6 +24,7 @@ vi.mock('../db/index.js', () => ({
|
|||||||
import { inviteStatus, generateInviteToken, createInvite, getInviteByToken, listInvites, listRedemptions, InviteValidationError } from './inviteService.js';
|
import { inviteStatus, generateInviteToken, createInvite, getInviteByToken, listInvites, listRedemptions, InviteValidationError } from './inviteService.js';
|
||||||
import { patchInvite, revokeInvite, InviteStateConflictError, InviteNotFoundError } from './inviteService.js';
|
import { patchInvite, revokeInvite, InviteStateConflictError, InviteNotFoundError } from './inviteService.js';
|
||||||
import { reinstateInvite } from './inviteService.js';
|
import { reinstateInvite } from './inviteService.js';
|
||||||
|
import { redeemInvite, deleteInvite, InviteUnavailableError } from './inviteService.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
function applyMigrations(db: Database.Database): void {
|
function applyMigrations(db: Database.Database): void {
|
||||||
@@ -464,3 +465,145 @@ describe('reinstateInvite', () => {
|
|||||||
expect(row?.usedCount).toBe(1);
|
expect(row?.usedCount).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('redeemInvite', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sqlite = new Database(':memory:');
|
||||||
|
sqlite.pragma('foreign_keys = ON');
|
||||||
|
applyMigrations(sqlite);
|
||||||
|
testDb = drizzle(sqlite, { schema });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('happy path: increments usedCount, writes redemption row, calls insertUser callback', () => {
|
||||||
|
const adminId = seedAdmin();
|
||||||
|
const inv = createInvite({ name: 'a', maxUses: 5, expiresAt: null }, adminId);
|
||||||
|
const newUserId = 'new-user-1';
|
||||||
|
const newUsername = 'alice';
|
||||||
|
|
||||||
|
let insertCalled = false;
|
||||||
|
const result = redeemInvite(inv.token, () => {
|
||||||
|
insertCalled = true;
|
||||||
|
// Caller's INSERT — uses the outer testDb handle (mocked getDb), which
|
||||||
|
// is correctly serialized into the same logical txn by better-sqlite3.
|
||||||
|
testDb.insert(schema.users).values({
|
||||||
|
id: newUserId,
|
||||||
|
username: newUsername,
|
||||||
|
passwordHash: 'x',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
return { id: newUserId, username: newUsername };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(insertCalled).toBe(true);
|
||||||
|
expect(result.id).toBe(newUserId);
|
||||||
|
expect(result.username).toBe(newUsername);
|
||||||
|
|
||||||
|
// user row exists
|
||||||
|
const user = testDb.select().from(schema.users).where(eq(schema.users.id, newUserId)).get();
|
||||||
|
expect(user?.username).toBe(newUsername);
|
||||||
|
|
||||||
|
// usedCount incremented
|
||||||
|
const row = testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, inv.id)).get();
|
||||||
|
expect(row?.usedCount).toBe(1);
|
||||||
|
|
||||||
|
// redemption row written
|
||||||
|
const redemptions = testDb.select().from(schema.inviteRedemptions).where(eq(schema.inviteRedemptions.inviteId, inv.id)).all();
|
||||||
|
expect(redemptions).toHaveLength(1);
|
||||||
|
expect(redemptions[0]?.userId).toBe(newUserId);
|
||||||
|
expect(redemptions[0]?.registrantUsername).toBe(newUsername);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws InviteUnavailableError when status is not active under txn (exhausted)', () => {
|
||||||
|
const adminId = seedAdmin();
|
||||||
|
const inv = createInvite({ name: 'a', maxUses: 1, expiresAt: null }, adminId);
|
||||||
|
// Simulate "another concurrent registration consumed the last slot just before this call"
|
||||||
|
testDb.update(schema.inviteLinks).set({ usedCount: 1 }).where(eq(schema.inviteLinks.id, inv.id)).run();
|
||||||
|
|
||||||
|
let insertCalled = false;
|
||||||
|
expect(() => redeemInvite(inv.token, () => {
|
||||||
|
insertCalled = true;
|
||||||
|
return { id: 'x', username: 'x' };
|
||||||
|
})).toThrow(InviteUnavailableError);
|
||||||
|
|
||||||
|
expect(insertCalled).toBe(false);
|
||||||
|
// No redemption row
|
||||||
|
const redemptions = testDb.select().from(schema.inviteRedemptions).where(eq(schema.inviteRedemptions.inviteId, inv.id)).all();
|
||||||
|
expect(redemptions).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aborts transaction (no usedCount increment, no redemption row) if insertUser throws', () => {
|
||||||
|
const adminId = seedAdmin();
|
||||||
|
const inv = createInvite({ name: 'a', maxUses: 5, expiresAt: null }, adminId);
|
||||||
|
|
||||||
|
expect(() => redeemInvite(inv.token, () => {
|
||||||
|
throw new Error('username collision');
|
||||||
|
})).toThrow('username collision');
|
||||||
|
|
||||||
|
// usedCount unchanged
|
||||||
|
const row = testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, inv.id)).get();
|
||||||
|
expect(row?.usedCount).toBe(0);
|
||||||
|
|
||||||
|
// No redemption row
|
||||||
|
const redemptions = testDb.select().from(schema.inviteRedemptions).where(eq(schema.inviteRedemptions.inviteId, inv.id)).all();
|
||||||
|
expect(redemptions).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws InviteUnavailableError when token not found', () => {
|
||||||
|
expect(() => redeemInvite('aaaaaaaaaaaaaaaaaaaaaa', () => ({ id: 'x', username: 'x' }))).toThrow(InviteUnavailableError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws InviteUnavailableError on revoked invite', () => {
|
||||||
|
const adminId = seedAdmin();
|
||||||
|
const inv = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId);
|
||||||
|
revokeInvite(inv.id);
|
||||||
|
|
||||||
|
let insertCalled = false;
|
||||||
|
expect(() => redeemInvite(inv.token, () => {
|
||||||
|
insertCalled = true;
|
||||||
|
return { id: 'x', username: 'x' };
|
||||||
|
})).toThrow(InviteUnavailableError);
|
||||||
|
|
||||||
|
expect(insertCalled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteInvite', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sqlite = new Database(':memory:');
|
||||||
|
sqlite.pragma('foreign_keys = ON');
|
||||||
|
applyMigrations(sqlite);
|
||||||
|
testDb = drizzle(sqlite, { schema });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes the invite and CASCADE-removes redemption rows', () => {
|
||||||
|
const adminId = seedAdmin();
|
||||||
|
const inv = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId);
|
||||||
|
|
||||||
|
// Seed a redemption referencing this invite
|
||||||
|
const userId = 'redeemer-1';
|
||||||
|
testDb.insert(schema.users).values({
|
||||||
|
id: userId, username: 'redeemer', passwordHash: 'x', createdAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
testDb.insert(schema.inviteRedemptions).values({
|
||||||
|
id: 'red-del-1',
|
||||||
|
inviteId: inv.id,
|
||||||
|
userId,
|
||||||
|
registrantUsername: 'redeemer',
|
||||||
|
redeemedAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
deleteInvite(inv.id);
|
||||||
|
|
||||||
|
// Invite row gone
|
||||||
|
const inviteRow = testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, inv.id)).get();
|
||||||
|
expect(inviteRow).toBeUndefined();
|
||||||
|
|
||||||
|
// Redemption row CASCADE-deleted
|
||||||
|
const redemptions = testDb.select().from(schema.inviteRedemptions).where(eq(schema.inviteRedemptions.inviteId, inv.id)).all();
|
||||||
|
expect(redemptions).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws InviteNotFoundError when id not found', () => {
|
||||||
|
expect(() => deleteInvite('nonexistent')).toThrow(InviteNotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -467,3 +467,93 @@ export function reinstateInvite(id: string, req: ReinstateInviteRequest): Reinst
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when an invite cannot be redeemed because its current state forbids
|
||||||
|
* it (token not found, revoked, expired, exhausted). Caller (HTTP register
|
||||||
|
* route) maps this to 403 Forbidden. The reason string is preserved in the
|
||||||
|
* message for debugging — the user-facing copy is constructed by the route.
|
||||||
|
*/
|
||||||
|
export class InviteUnavailableError extends Error {
|
||||||
|
constructor(reason: string) {
|
||||||
|
super(`Invite unavailable: ${reason}`);
|
||||||
|
this.name = 'InviteUnavailableError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result returned by the `insertUser` callback to `redeemInvite`. Captures
|
||||||
|
* just the fields needed to write the redemption row (id for the FK,
|
||||||
|
* username for the forensic snapshot in `registrant_username`).
|
||||||
|
*/
|
||||||
|
export interface RedemptionUserResult {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically redeem an invite token. The caller-supplied `insertUser` callback
|
||||||
|
* runs inside the same SQLite transaction as the usedCount increment + redemption
|
||||||
|
* insert. If insertUser throws, the entire transaction rolls back — the invite
|
||||||
|
* is NOT consumed for failed registrations (e.g. username uniqueness collisions).
|
||||||
|
*
|
||||||
|
* Re-derives status under the transaction to close the TOCTOU window between
|
||||||
|
* `/api/auth/check-invite` (which the client may call seconds before submit)
|
||||||
|
* and the actual register POST: another user could have consumed the last slot
|
||||||
|
* in between. Re-checking inside the txn ensures the slot we increment is the
|
||||||
|
* one we observed available.
|
||||||
|
*/
|
||||||
|
export function redeemInvite(
|
||||||
|
token: string,
|
||||||
|
insertUser: () => RedemptionUserResult,
|
||||||
|
): RedemptionUserResult {
|
||||||
|
const db = getDb();
|
||||||
|
return db.transaction((tx) => {
|
||||||
|
const row = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.token, token)).get();
|
||||||
|
if (!row) throw new InviteUnavailableError('not found');
|
||||||
|
const status = inviteStatus(row);
|
||||||
|
if (status !== 'active') {
|
||||||
|
throw new InviteUnavailableError(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The insertUser callback runs inside the transaction. The caller's INSERT
|
||||||
|
// statement uses the outer `db` connection, but better-sqlite3 serializes
|
||||||
|
// all writes regardless of which Drizzle handle issued them, so the user
|
||||||
|
// insert joins the same atomic unit. If insertUser throws, the entire
|
||||||
|
// transaction rolls back including the usedCount bump and redemption row.
|
||||||
|
const userResult = insertUser();
|
||||||
|
|
||||||
|
tx.update(schema.inviteLinks)
|
||||||
|
.set({ usedCount: row.usedCount + 1 })
|
||||||
|
.where(eq(schema.inviteLinks.id, row.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
tx.insert(schema.inviteRedemptions).values({
|
||||||
|
id: generateSnowflake(),
|
||||||
|
inviteId: row.id,
|
||||||
|
userId: userResult.id,
|
||||||
|
registrantUsername: userResult.username,
|
||||||
|
redeemedAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
return userResult;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanently delete an invite. Redemption rows for this invite are removed
|
||||||
|
* via `ON DELETE CASCADE` on `invite_redemptions.invite_id` — this is the
|
||||||
|
* documented destructive intent of "delete the invite and its history".
|
||||||
|
*
|
||||||
|
* No transaction needed: deleteInvite has no read-modify-write state semantics
|
||||||
|
* that other concurrent mutators would race against. The existence check is
|
||||||
|
* for the 404 response only; if a concurrent process deletes the row between
|
||||||
|
* the SELECT and the DELETE, the DELETE is a harmless no-op and the caller
|
||||||
|
* still observes the row gone afterwards.
|
||||||
|
*/
|
||||||
|
export function deleteInvite(id: string): void {
|
||||||
|
const db = getDb();
|
||||||
|
const row = db.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get();
|
||||||
|
if (!row) throw new InviteNotFoundError();
|
||||||
|
db.delete(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).run();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user