feat(invites): reinstateInvite with revoked-vs-archived branching
This commit is contained in:
@@ -23,6 +23,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 { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
function applyMigrations(db: Database.Database): void {
|
function applyMigrations(db: Database.Database): void {
|
||||||
@@ -374,3 +375,74 @@ describe('revokeInvite', () => {
|
|||||||
expect(() => revokeInvite('nonexistent')).toThrow(InviteNotFoundError);
|
expect(() => revokeInvite('nonexistent')).toThrow(InviteNotFoundError);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('reinstateInvite', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sqlite = new Database(':memory:');
|
||||||
|
sqlite.pragma('foreign_keys = ON');
|
||||||
|
applyMigrations(sqlite);
|
||||||
|
testDb = drizzle(sqlite, { schema });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Path A — was revoked: rotates token, clears revokedAt, applies bumps', () => {
|
||||||
|
const adminId = seedAdmin();
|
||||||
|
const inv = createInvite({ name: 'a', maxUses: 5, expiresAt: null }, adminId);
|
||||||
|
const originalToken = inv.token;
|
||||||
|
revokeInvite(inv.id);
|
||||||
|
|
||||||
|
const result = reinstateInvite(inv.id, { maxUses: 10 });
|
||||||
|
expect(result.tokenRotated).toBe(true);
|
||||||
|
expect(result.invite.token).not.toBe(originalToken);
|
||||||
|
expect(result.invite.token).toMatch(/^[A-Za-z0-9_-]{22}$/);
|
||||||
|
expect(result.invite.revokedAt).toBeNull();
|
||||||
|
expect(result.invite.maxUses).toBe(10);
|
||||||
|
expect(result.invite.status).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Path B — exhausted: keeps same token, applies maxUses bump', () => {
|
||||||
|
const adminId = seedAdmin();
|
||||||
|
const inv = createInvite({ name: 'a', maxUses: 1, expiresAt: null }, adminId);
|
||||||
|
// Exhaust it
|
||||||
|
testDb.update(schema.inviteLinks).set({ usedCount: 1 }).where(eq(schema.inviteLinks.id, inv.id)).run();
|
||||||
|
|
||||||
|
const result = reinstateInvite(inv.id, { maxUses: 5 });
|
||||||
|
expect(result.tokenRotated).toBe(false);
|
||||||
|
expect(result.invite.token).toBe(inv.token);
|
||||||
|
expect(result.invite.maxUses).toBe(5);
|
||||||
|
expect(result.invite.status).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Path B — expired: keeps same token, bumps expiresAt', () => {
|
||||||
|
const adminId = seedAdmin();
|
||||||
|
const inv = createInvite({ name: 'a', maxUses: null, expiresAt: Date.now() + 100_000 }, adminId);
|
||||||
|
// Move to past to expire
|
||||||
|
testDb.update(schema.inviteLinks).set({ expiresAt: Date.now() - 1000 }).where(eq(schema.inviteLinks.id, inv.id)).run();
|
||||||
|
|
||||||
|
const future = Date.now() + 86_400_000;
|
||||||
|
const result = reinstateInvite(inv.id, { expiresAt: future });
|
||||||
|
expect(result.tokenRotated).toBe(false);
|
||||||
|
expect(result.invite.token).toBe(inv.token);
|
||||||
|
expect(result.invite.expiresAt).toBe(future);
|
||||||
|
expect(result.invite.status).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Path C — already-active: throws InviteStateConflictError', () => {
|
||||||
|
const adminId = seedAdmin();
|
||||||
|
const inv = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId);
|
||||||
|
expect(() => reinstateInvite(inv.id, {})).toThrow(InviteStateConflictError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws InviteValidationError when result is still non-active (caller did not bump enough)', () => {
|
||||||
|
const adminId = seedAdmin();
|
||||||
|
const inv = createInvite({ name: 'a', maxUses: 1, expiresAt: null }, adminId);
|
||||||
|
// Exhaust it
|
||||||
|
testDb.update(schema.inviteLinks).set({ usedCount: 1 }).where(eq(schema.inviteLinks.id, inv.id)).run();
|
||||||
|
|
||||||
|
// Caller did not bump maxUses → still exhausted → must throw
|
||||||
|
expect(() => reinstateInvite(inv.id, {})).toThrow(InviteValidationError);
|
||||||
|
|
||||||
|
// Verify the txn rolled back: row still has maxUses=1 (no partial update)
|
||||||
|
const row = testDb.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, inv.id)).get();
|
||||||
|
expect(row?.maxUses).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ import { eq, desc } from 'drizzle-orm';
|
|||||||
import { getDb, schema } from '../db/index.js';
|
import { getDb, schema } from '../db/index.js';
|
||||||
import { generateSnowflake } from './snowflake.js';
|
import { generateSnowflake } from './snowflake.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import type { InviteLinkSummary, CreateInviteRequest, InviteRedemption, UpdateInviteRequest } from '@backspace/shared';
|
import type {
|
||||||
|
InviteLinkSummary,
|
||||||
|
CreateInviteRequest,
|
||||||
|
InviteRedemption,
|
||||||
|
UpdateInviteRequest,
|
||||||
|
ReinstateInviteRequest,
|
||||||
|
ReinstateInviteResponse,
|
||||||
|
} from '@backspace/shared';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derived status of an invite link. Mirrors the `InviteStatus` union exported
|
* Derived status of an invite link. Mirrors the `InviteStatus` union exported
|
||||||
@@ -381,3 +388,76 @@ export function revokeInvite(id: string): InviteLinkSummary {
|
|||||||
return rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx));
|
return rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reinstate a non-active invite back to `active`. Three branches per spec §3.1:
|
||||||
|
*
|
||||||
|
* - **Path A (revoked)**: rotates the token (security boundary — old shared
|
||||||
|
* links must stop working) and clears `revokedAt`. Caller may also bump
|
||||||
|
* `maxUses` / `expiresAt` in the same call.
|
||||||
|
* - **Path B (expired/exhausted)**: preserves the token. Caller MUST supply
|
||||||
|
* bumps that push the row back into derived `active` state, otherwise the
|
||||||
|
* txn rolls back with `InviteValidationError` (we never leave an invite
|
||||||
|
* half-reinstated, e.g. exhausted-and-still-exhausted with no token rotation
|
||||||
|
* and no state change).
|
||||||
|
* - **Path C (already active)**: rejected with `InviteStateConflictError`
|
||||||
|
* (mapped to 409). Reinstating an active invite is meaningless and would
|
||||||
|
* surprise an admin who clicked the wrong row.
|
||||||
|
*
|
||||||
|
* Wrapped in a SQLite transaction with an in-txn re-read so the post-update
|
||||||
|
* status check sees the row as the next reader would. If the post-state isn't
|
||||||
|
* `active`, the throw aborts the txn and the row reverts.
|
||||||
|
*/
|
||||||
|
export function reinstateInvite(id: string, req: ReinstateInviteRequest): ReinstateInviteResponse {
|
||||||
|
const db = getDb();
|
||||||
|
return db.transaction((tx) => {
|
||||||
|
const row = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get();
|
||||||
|
if (!row) throw new InviteNotFoundError();
|
||||||
|
|
||||||
|
const currentStatus = inviteStatus(row);
|
||||||
|
if (currentStatus === 'active') {
|
||||||
|
throw new InviteStateConflictError('Invite is already active');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: Partial<typeof schema.inviteLinks.$inferInsert> = {};
|
||||||
|
let tokenRotated = false;
|
||||||
|
|
||||||
|
if (currentStatus === 'revoked') {
|
||||||
|
updates.revokedAt = null;
|
||||||
|
updates.token = generateInviteToken();
|
||||||
|
tokenRotated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.maxUses !== undefined) {
|
||||||
|
const v = validateMaxUses(req.maxUses);
|
||||||
|
if (v !== null && v < row.usedCount) {
|
||||||
|
throw new InviteValidationError(
|
||||||
|
`maxUses (${v}) cannot be less than current usedCount (${row.usedCount})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
updates.maxUses = v;
|
||||||
|
}
|
||||||
|
if (req.expiresAt !== undefined) {
|
||||||
|
updates.expiresAt = validateExpiresAt(req.expiresAt, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
tx.update(schema.inviteLinks).set(updates).where(eq(schema.inviteLinks.id, id)).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get();
|
||||||
|
if (!updated) throw new Error('Failed to read updated invite');
|
||||||
|
|
||||||
|
if (inviteStatus(updated) !== 'active') {
|
||||||
|
// Caller did not bump enough — abort the txn so nothing is half-applied
|
||||||
|
throw new InviteValidationError(
|
||||||
|
'Reinstate would leave invite in non-active state. Bump maxUses and/or expiresAt.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
invite: rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx)),
|
||||||
|
tokenRotated,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user