Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions apps/sim/app/api/enterprise-owner-claims/[id]/accept/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,6 @@ export const POST = withRouteHandler(
if (!session?.user?.id || !session.user.email) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
}
if (!session.user.emailVerified) {
return NextResponse.json(
{
error: 'email-unverified',
message: 'Verify the invited email before accepting Enterprise ownership.',
},
{ status: 403 }
)
}
const parsed = await parseRequest(acceptEnterpriseOwnerClaimContract, request, context)
if (!parsed.success) return parsed.response
const result = await acceptEnterpriseOwnerClaim({
Expand Down
115 changes: 115 additions & 0 deletions apps/sim/app/api/enterprise-owner-claims/[id]/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* @vitest-environment node
*/
import { authMockFns, createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
acceptClaim: vi.fn(),
getClaimDetails: vi.fn(),
}))

vi.mock('@/lib/billing/enterprise-owner-claim', () => ({
acceptEnterpriseOwnerClaim: mocks.acceptClaim,
getEnterpriseOwnerClaimDetails: mocks.getClaimDetails,
EnterpriseOwnerClaimEmailMismatchError: class EnterpriseOwnerClaimEmailMismatchError extends Error {},
EnterpriseOwnerClaimWorkspaceLimitError: class EnterpriseOwnerClaimWorkspaceLimitError extends Error {},
}))

vi.mock('@/lib/billing/enterprise-provisioning', () => ({
EnterpriseProvisioningError: class EnterpriseProvisioningError extends Error {},
}))

import { POST } from '@/app/api/enterprise-owner-claims/[id]/accept/route'
import { GET } from '@/app/api/enterprise-owner-claims/[id]/route'

const claim = {
id: 'claim-1',
ownerEmail: 'owner@example.com',
organizationName: 'Acme',
organizationId: null,
provisioningOperationId: null,
stage: 'owner_acceptance' as const,
status: 'awaiting_owner' as const,
error: null,
expiresAt: '2026-09-04T00:00:00.000Z',
createdAt: '2026-08-28T00:00:00.000Z',
updatedAt: '2026-08-28T00:00:00.000Z',
}

describe('Enterprise owner claim routes', () => {
beforeEach(() => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue({
user: {
id: 'owner-1',
name: 'Owner',
email: 'owner@example.com',
emailVerified: false,
},
})
})

it('lets the invited account review the mailed claim before email verification', async () => {
mocks.getClaimDetails.mockResolvedValue({
...claim,
invoiceAmountUsd: 10_000,
billingInterval: 'year',
seats: 10,
invitations: 0,
workspacePreview: { workspacesToMove: [], createsDefaultWorkspace: true },
acceptanceReview: { canAccept: true, reason: null, requiredSeats: 1 },
})

const response = await GET(
createMockRequest(
'GET',
undefined,
{},
'http://localhost/api/enterprise-owner-claims/claim-1?token=secure-token'
),
{ params: Promise.resolve({ id: 'claim-1' }) }
)

expect(response.status).toBe(200)
expect(mocks.getClaimDetails).toHaveBeenCalledWith({
claimId: 'claim-1',
token: 'secure-token',
userId: 'owner-1',
userEmail: 'owner@example.com',
})
})

it('lets the acceptance transaction verify an unverified invited account', async () => {
mocks.acceptClaim.mockResolvedValue({
success: true,
claim,
redirectPath: '/workspace',
})

const response = await POST(
createMockRequest(
'POST',
{
token: 'secure-token',
disclosedWorkspaceIds: [],
disclosedCreatesDefaultWorkspace: true,
},
{},
'http://localhost/api/enterprise-owner-claims/claim-1/accept'
),
{ params: Promise.resolve({ id: 'claim-1' }) }
)

expect(response.status).toBe(200)
expect(mocks.acceptClaim).toHaveBeenCalledWith({
claimId: 'claim-1',
token: 'secure-token',
userId: 'owner-1',
userEmail: 'owner@example.com',
userName: 'Owner',
disclosedWorkspaceIds: [],
disclosedCreatesDefaultWorkspace: true,
})
})
})
9 changes: 0 additions & 9 deletions apps/sim/app/api/enterprise-owner-claims/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,6 @@ export const GET = withRouteHandler(
if (!session?.user?.id || !session.user.email) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
}
if (!session.user.emailVerified) {
return NextResponse.json(
{
error: 'email-unverified',
message: 'Verify the invited email before reviewing Enterprise ownership.',
},
{ status: 403 }
)
}
const parsed = await parseRequest(getEnterpriseOwnerClaimContract, request, context)
if (!parsed.success) return parsed.response
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,7 @@ export default function EnterpriseOwnerClaim({ registrationDisabled }: Enterpris
apiErrorMessage(detailsQuery.error) ??
(queryErrorCode === 'email-mismatch'
? 'This invitation was sent to a different email address.'
: queryErrorCode === 'email-unverified'
? 'Verify the invited email, then return to this owner invitation.'
: 'This Enterprise invitation is invalid or unavailable.'),
: 'This Enterprise invitation is invalid or unavailable.'),
}
: null)
if (error) {
Expand Down
58 changes: 57 additions & 1 deletion apps/sim/lib/billing/enterprise-owner-claim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
*/
import { db } from '@sim/db'
import { member, outboxEvent, user, workspace } from '@sim/db/schema'
import { queueTableRows, resetDbChainMock } from '@sim/testing'
import {
dbChainMockFns,
flattenMockConditions,
queueTableRows,
resetDbChainMock,
} from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
Expand Down Expand Up @@ -154,6 +159,7 @@ describe('Enterprise future-owner claims', () => {
error: null,
updatedAt: now.toISOString(),
})
dbChainMockFns.returning.mockResolvedValue([{ id: 'owner-1' }])
})

it('rejects the future-owner path when an account already exists', async () => {
Expand Down Expand Up @@ -189,6 +195,7 @@ describe('Enterprise future-owner claims', () => {
disclosedCreatesDefaultWorkspace: false,
})
).resolves.toEqual({ success: false, kind: 'disclosure-outdated' })
expect(dbChainMockFns.update).not.toHaveBeenCalled()
expect(mocks.createOrganization).not.toHaveBeenCalled()
expect(mocks.enqueue).not.toHaveBeenCalled()
expect(mocks.process).not.toHaveBeenCalled()
Expand Down Expand Up @@ -268,6 +275,11 @@ describe('Enterprise future-owner claims', () => {
expect.anything(),
expect.objectContaining({ ownerUserId: 'owner-1', name: 'Acme' })
)
expect(dbChainMockFns.update).toHaveBeenCalledWith(user)
expect(dbChainMockFns.set).toHaveBeenCalledWith({
emailVerified: true,
updatedAt: now,
})
expect(mocks.patchPayload).toHaveBeenCalledWith(
expect.anything(),
'claim-1',
Expand All @@ -288,6 +300,50 @@ describe('Enterprise future-owner claims', () => {
)
})

it('rejects acceptance when the canonical account email no longer matches the claim', async () => {
queueTableRows(outboxEvent, [claimRow()])
queueTableRows(member, [])
queueTableRows(workspace, [{ id: 'workspace-1' }])
dbChainMockFns.returning.mockResolvedValueOnce([])
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

await expect(
acceptEnterpriseOwnerClaim({
claimId: 'claim-1',
token: 'secure-token',
userId: 'owner-1',
userEmail: 'owner@example.com',
userName: 'Owner',
disclosedWorkspaceIds: ['workspace-1'],
disclosedCreatesDefaultWorkspace: false,
})
).resolves.toEqual({ success: false, kind: 'email-mismatch' })

const updateConditions = flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0])
expect(
updateConditions.some(
(condition) =>
condition.type === 'eq' && condition.left === user.id && condition.right === 'owner-1'
)
).toBe(true)
const emailScope = updateConditions.find((condition) => condition.type === 'or')
const emailConditions = Array.isArray(emailScope?.conditions) ? emailScope.conditions : []
expect(
emailConditions.some(
(condition) =>
condition?.type === 'eq' &&
condition.left === user.normalizedEmail &&
condition.right === request.ownerEmail
)
).toBe(true)
expect(
emailConditions.filter(
(condition) => condition?.type === 'eq' && condition.right === request.ownerEmail
)
).toHaveLength(2)
expect(mocks.createOrganization).not.toHaveBeenCalled()
expect(mocks.enqueue).not.toHaveBeenCalled()
})

it('activates through the canonical Enterprise issuance operation only after acceptance', async () => {
const accepted = {
acceptedAt: '2026-08-20T12:00:00.000Z',
Expand Down
17 changes: 17 additions & 0 deletions apps/sim/lib/billing/enterprise-owner-claim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -964,6 +964,23 @@ export async function acceptEnterpriseOwnerClaim(params: {
invitationEmails: payload.request.invitations.map((invitation) => invitation.email),
})

const [verifiedOwner] = await tx
.update(user)
.set({ emailVerified: true, updatedAt: new Date() })
.where(
and(
eq(user.id, params.userId),
or(
eq(user.normalizedEmail, payload.request.ownerEmail),
eq(sql<string>`lower(trim(${user.email}))`, payload.request.ownerEmail)
)
)
)
.returning({ id: user.id })
if (!verifiedOwner) {
return { success: false as const, kind: 'email-mismatch' as const }
}

if (createsDefaultWorkspace) {
const defaultWorkspace = await createDefaultPersonalWorkspaceInTransaction(tx, {
userId: params.userId,
Expand Down
Loading