Skip to content

Commit aef6f8c

Browse files
committed
checkpoint consistency fixes
1 parent d8a0ea2 commit aef6f8c

18 files changed

Lines changed: 661 additions & 94 deletions

File tree

apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,49 @@ describe('organization invitation route', () => {
192192
expect(mockDbState.updateCalls).toEqual([])
193193
})
194194

195+
it('only lets the invitee reject an organization invitation', async () => {
196+
mockGetSession.mockResolvedValue(
197+
createSession({
198+
userId: 'user-2',
199+
email: 'someone-else@example.com',
200+
name: 'Someone Else',
201+
})
202+
)
203+
mockDbState.selectResults = [
204+
[
205+
{
206+
id: 'invite-1',
207+
organizationId: 'org-1',
208+
status: 'pending',
209+
email: 'invitee@example.com',
210+
role: 'member',
211+
expiresAt: new Date(Date.now() + 1000 * 60 * 60),
212+
},
213+
],
214+
[
215+
{
216+
id: 'user-2',
217+
email: 'someone-else@example.com',
218+
},
219+
],
220+
]
221+
222+
const response = await PUT(
223+
new Request('http://localhost/api/organizations/org-1/invitations/invite-1', {
224+
method: 'PUT',
225+
headers: { 'Content-Type': 'application/json' },
226+
body: JSON.stringify({ status: 'rejected' }),
227+
}) as any,
228+
{ params: Promise.resolve({ id: 'org-1', invitationId: 'invite-1' }) }
229+
)
230+
231+
expect(response.status).toBe(403)
232+
await expect(response.json()).resolves.toEqual({
233+
error: 'Email mismatch. You can only reject invitations sent to your email address.',
234+
})
235+
expect(mockDbState.updateCalls).toEqual([])
236+
})
237+
195238
it('extends linked workspace invitations when resending an org invitation', async () => {
196239
mockGetSession.mockResolvedValue(
197240
createSession({

apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ export async function PUT(
266266
return NextResponse.json({ error: 'Invitation has expired' }, { status: 400 })
267267
}
268268

269-
if (status === 'accepted') {
269+
if (status === 'accepted' || status === 'rejected') {
270270
const userData = await db
271271
.select()
272272
.from(user)
@@ -275,7 +275,12 @@ export async function PUT(
275275

276276
if (!userData || userData.email.toLowerCase() !== orgInvitation.email.toLowerCase()) {
277277
return NextResponse.json(
278-
{ error: 'Email mismatch. You can only accept invitations sent to your email address.' },
278+
{
279+
error:
280+
status === 'accepted'
281+
? 'Email mismatch. You can only accept invitations sent to your email address.'
282+
: 'Email mismatch. You can only reject invitations sent to your email address.',
283+
},
279284
{ status: 403 }
280285
)
281286
}
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { auditMock, createSession, loggerMock } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const {
8+
mockDbState,
9+
mockGetSession,
10+
mockSendEmail,
11+
mockValidateInvitationsAllowed,
12+
mockValidateSeatAvailability,
13+
} = vi.hoisted(() => ({
14+
mockDbState: {
15+
selectResults: [] as any[],
16+
updateCalls: [] as Array<{ table: unknown; values: Record<string, unknown> }>,
17+
insertCalls: [] as Array<{ table: unknown; values: unknown }>,
18+
},
19+
mockGetSession: vi.fn(),
20+
mockSendEmail: vi.fn(),
21+
mockValidateInvitationsAllowed: vi.fn(),
22+
mockValidateSeatAvailability: vi.fn(),
23+
}))
24+
25+
function createSelectChain() {
26+
const chain: any = {}
27+
chain.from = vi.fn().mockReturnValue(chain)
28+
chain.innerJoin = vi.fn().mockReturnValue(chain)
29+
chain.where = vi.fn().mockReturnValue(chain)
30+
chain.limit = vi
31+
.fn()
32+
.mockImplementation(() => Promise.resolve(mockDbState.selectResults.shift() ?? []))
33+
chain.then = vi.fn().mockImplementation((callback: (rows: any[]) => unknown) => {
34+
const rows = mockDbState.selectResults.shift() ?? []
35+
return Promise.resolve(callback(rows))
36+
})
37+
return chain
38+
}
39+
40+
function createUpdateChain(table: unknown) {
41+
return {
42+
set: vi.fn().mockImplementation((values: Record<string, unknown>) => {
43+
mockDbState.updateCalls.push({ table, values })
44+
return {
45+
where: vi.fn().mockResolvedValue(undefined),
46+
}
47+
}),
48+
}
49+
}
50+
51+
vi.mock('@sim/db', () => ({
52+
db: {
53+
select: vi.fn().mockImplementation(() => createSelectChain()),
54+
insert: vi.fn().mockImplementation((table: unknown) => ({
55+
values: vi.fn().mockImplementation((values: unknown) => {
56+
mockDbState.insertCalls.push({ table, values })
57+
return Promise.resolve(undefined)
58+
}),
59+
})),
60+
update: vi.fn().mockImplementation((table: unknown) => createUpdateChain(table)),
61+
transaction: vi.fn().mockImplementation(async (callback: (tx: unknown) => Promise<void>) =>
62+
callback({
63+
update: vi.fn().mockImplementation((table: unknown) => createUpdateChain(table)),
64+
})
65+
),
66+
},
67+
}))
68+
69+
vi.mock('@sim/db/schema', () => ({
70+
invitation: {
71+
id: 'invitation.id',
72+
organizationId: 'invitation.organizationId',
73+
status: 'invitation.status',
74+
email: 'invitation.email',
75+
},
76+
member: {
77+
organizationId: 'member.organizationId',
78+
userId: 'member.userId',
79+
role: 'member.role',
80+
},
81+
organization: {
82+
id: 'organization.id',
83+
name: 'organization.name',
84+
},
85+
user: {
86+
id: 'user.id',
87+
name: 'user.name',
88+
email: 'user.email',
89+
},
90+
workspace: {
91+
id: 'workspace.id',
92+
name: 'workspace.name',
93+
organizationId: 'workspace.organizationId',
94+
workspaceMode: 'workspace.workspaceMode',
95+
},
96+
workspaceInvitation: {
97+
id: 'workspaceInvitation.id',
98+
orgInvitationId: 'workspaceInvitation.orgInvitationId',
99+
status: 'workspaceInvitation.status',
100+
updatedAt: 'workspaceInvitation.updatedAt',
101+
},
102+
}))
103+
104+
vi.mock('drizzle-orm', () => ({
105+
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
106+
eq: vi.fn((field: unknown, value: unknown) => ({ field, value })),
107+
inArray: vi.fn((field: unknown, values: unknown[]) => ({ field, values })),
108+
isNull: vi.fn((field: unknown) => ({ field, type: 'isNull' })),
109+
or: vi.fn((...conditions: unknown[]) => ({ type: 'or', conditions })),
110+
}))
111+
112+
vi.mock('@sim/logger', () => loggerMock)
113+
114+
vi.mock('@/components/emails', () => ({
115+
getEmailSubject: vi.fn().mockReturnValue('Organization invite'),
116+
renderBatchInvitationEmail: vi.fn().mockResolvedValue('<html></html>'),
117+
renderInvitationEmail: vi.fn().mockResolvedValue('<html></html>'),
118+
}))
119+
120+
vi.mock('@/lib/audit/log', () => auditMock)
121+
122+
vi.mock('@/lib/auth', () => ({
123+
getSession: mockGetSession,
124+
}))
125+
126+
vi.mock('@/lib/billing/validation/seat-management', () => ({
127+
validateBulkInvitations: vi.fn(),
128+
validateSeatAvailability: mockValidateSeatAvailability,
129+
}))
130+
131+
vi.mock('@/lib/core/utils/urls', () => ({
132+
getBaseUrl: vi.fn().mockReturnValue('https://test.sim.ai'),
133+
}))
134+
135+
vi.mock('@/lib/core/utils/uuid', () => ({
136+
generateId: vi.fn().mockReturnValue('generated-id'),
137+
}))
138+
139+
vi.mock('@/lib/messaging/email/mailer', () => ({
140+
sendEmail: mockSendEmail,
141+
}))
142+
143+
vi.mock('@/lib/messaging/email/validation', () => ({
144+
quickValidateEmail: vi.fn((email: string) => ({ isValid: email.includes('@') })),
145+
}))
146+
147+
vi.mock('@/lib/workspaces/permissions/utils', () => ({
148+
hasWorkspaceAdminAccess: vi.fn().mockResolvedValue(true),
149+
}))
150+
151+
vi.mock('@/lib/workspaces/policy', () => ({
152+
isOrganizationWorkspace: vi.fn().mockReturnValue(true),
153+
}))
154+
155+
vi.mock('@/ee/access-control/utils/permission-check', () => ({
156+
InvitationsNotAllowedError: class InvitationsNotAllowedError extends Error {},
157+
validateInvitationsAllowed: mockValidateInvitationsAllowed,
158+
}))
159+
160+
import { POST } from '@/app/api/organizations/[id]/invitations/route'
161+
162+
describe('POST /api/organizations/[id]/invitations', () => {
163+
beforeEach(() => {
164+
vi.clearAllMocks()
165+
mockDbState.selectResults = []
166+
mockDbState.updateCalls = []
167+
mockDbState.insertCalls = []
168+
mockValidateInvitationsAllowed.mockResolvedValue(undefined)
169+
mockValidateSeatAvailability.mockResolvedValue({
170+
canInvite: true,
171+
currentSeats: 1,
172+
maxSeats: 5,
173+
availableSeats: 4,
174+
})
175+
})
176+
177+
it('cancels pending invite rows and reports failure when email delivery fails', async () => {
178+
mockGetSession.mockResolvedValue(
179+
createSession({
180+
userId: 'user-1',
181+
email: 'owner@example.com',
182+
name: 'Owner',
183+
})
184+
)
185+
mockDbState.selectResults = [
186+
[{ role: 'owner' }],
187+
[{ name: 'Org One' }],
188+
[],
189+
[],
190+
[{ name: 'Owner' }],
191+
]
192+
mockSendEmail.mockResolvedValue({
193+
success: false,
194+
message: 'mailer unavailable',
195+
})
196+
197+
const response = await POST(
198+
new Request('http://localhost/api/organizations/org-1/invitations', {
199+
method: 'POST',
200+
headers: { 'Content-Type': 'application/json' },
201+
body: JSON.stringify({
202+
emails: ['invitee@example.com'],
203+
}),
204+
}) as any,
205+
{ params: Promise.resolve({ id: 'org-1' }) }
206+
)
207+
208+
expect(response.status).toBe(502)
209+
await expect(response.json()).resolves.toEqual({
210+
success: false,
211+
error: 'Failed to send invitation emails.',
212+
message: 'No invitation emails could be delivered.',
213+
data: {
214+
invitationsSent: 0,
215+
invitedEmails: [],
216+
failedInvitations: [
217+
{
218+
email: 'invitee@example.com',
219+
error: 'mailer unavailable',
220+
},
221+
],
222+
existingMembers: [],
223+
pendingInvitations: [],
224+
invalidEmails: [],
225+
workspaceInvitations: 0,
226+
seatInfo: {
227+
seatsUsed: 1,
228+
maxSeats: 5,
229+
availableSeats: 4,
230+
},
231+
},
232+
})
233+
expect(mockDbState.updateCalls).toEqual([
234+
{
235+
table: expect.objectContaining({
236+
id: 'invitation.id',
237+
organizationId: 'invitation.organizationId',
238+
}),
239+
values: { status: 'cancelled' },
240+
},
241+
{
242+
table: expect.objectContaining({
243+
id: 'workspaceInvitation.id',
244+
orgInvitationId: 'workspaceInvitation.orgInvitationId',
245+
}),
246+
values: expect.objectContaining({
247+
status: 'cancelled',
248+
updatedAt: expect.any(Date),
249+
}),
250+
},
251+
])
252+
})
253+
})

0 commit comments

Comments
 (0)