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
179 changes: 179 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'

const {
mockCanOpenOrganizationSettingsSection,
mockGetSession,
mockGetWorkspaceHostContext,
mockHasWorkspaceInboxAccess,
mockHasWorkspaceSandboxAccess,
mockIsForkingAvailable,
mockIsOrganizationOnEnterprisePlan,
mockIsOrganizationSettingsSectionAvailable,
mockNotFound,
mockRedirect,
mockResolveWorkspaceGroup,
mockResolveWorkspaceNavigation,
} = vi.hoisted(() => ({
mockCanOpenOrganizationSettingsSection: vi.fn(),
mockGetSession: vi.fn(),
mockGetWorkspaceHostContext: vi.fn(),
mockHasWorkspaceInboxAccess: vi.fn(),
mockHasWorkspaceSandboxAccess: vi.fn(),
mockIsForkingAvailable: vi.fn(),
mockIsOrganizationOnEnterprisePlan: vi.fn(),
mockIsOrganizationSettingsSectionAvailable: vi.fn(),
mockNotFound: vi.fn(() => {
throw new Error('NEXT_NOT_FOUND')
}),
mockRedirect: vi.fn((href: string) => {
throw new Error(`NEXT_REDIRECT:${href}`)
}),
mockResolveWorkspaceGroup: vi.fn(),
mockResolveWorkspaceNavigation: vi.fn(),
}))

vi.mock('next/navigation', () => ({
notFound: mockNotFound,
redirect: mockRedirect,
}))

vi.mock('@/components/settings/navigation', () => ({
getOrganizationSettingsFeatures: vi.fn(() => ({})),
isOrganizationSettingsSectionAvailable: mockIsOrganizationSettingsSectionAvailable,
resolveWorkspaceNavigation: mockResolveWorkspaceNavigation,
}))

vi.mock('@/lib/auth', () => ({
getSession: mockGetSession,
}))

vi.mock('@/lib/billing', () => ({
isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan,
}))

vi.mock('@/lib/billing/core/subscription', () => ({
hasWorkspaceInboxAccess: mockHasWorkspaceInboxAccess,
hasWorkspaceSandboxAccess: mockHasWorkspaceSandboxAccess,
}))

vi.mock('@/lib/core/config/env', () => ({
getEnv: vi.fn(),
isTruthy: vi.fn(() => false),
}))

vi.mock('@/lib/core/config/env-flags', () => ({
isBillingEnabled: true,
isHosted: true,
}))

vi.mock('@/lib/organizations/settings-access', () => ({
canOpenOrganizationSettingsSection: mockCanOpenOrganizationSettingsSection,
}))

vi.mock('@/lib/permissions/super-user', () => ({
isPlatformAdmin: vi.fn(() => false),
}))

vi.mock('@/lib/workspaces/host-context', () => ({
getWorkspaceHostContextForViewer: mockGetWorkspaceHostContext,
}))

vi.mock('@/app/_shell/providers/get-query-client', () => ({
getQueryClient: vi.fn(),
}))

vi.mock('@/app/workspace/[workspaceId]/settings/navigation', () => ({
allNavigationItems: [{ id: 'general' }, { id: 'billing' }, { id: 'secrets' }, { id: 'sessions' }],
getSettingsSectionMeta: vi.fn(() => null),
}))

vi.mock('@/ee/access-control/utils/permission-check', () => ({
resolveWorkspaceGroup: mockResolveWorkspaceGroup,
}))

vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({
isForkingAvailableForWorkspace: mockIsForkingAvailable,
}))

vi.mock('@/app/workspace/[workspaceId]/settings/[section]/prefetch', () => ({
prefetchGeneralSettings: vi.fn(),
}))

vi.mock('@/app/workspace/[workspaceId]/settings/[section]/settings', () => ({
SettingsPage: vi.fn(() => null),
}))

import WorkspaceSettingsSectionPage from '@/app/workspace/[workspaceId]/settings/[section]/page'

const PERSONAL_HOST_CONTEXT = {
workspace: {
id: 'workspace-b',
billedAccountUserId: 'owner-b',
},
hostOrganizationId: null,
ownerBilling: {
isEnterprise: false,
},
viewer: {
permission: 'admin',
isHostOrganizationAdmin: false,
},
}

function pageProps(section: string) {
return {
params: Promise.resolve({ workspaceId: 'workspace-b', section }),
}
}

describe('WorkspaceSettingsSectionPage unavailable sections', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetSession.mockResolvedValue({ user: { id: 'viewer-a' } })
mockGetWorkspaceHostContext.mockResolvedValue(PERSONAL_HOST_CONTEXT)
mockResolveWorkspaceNavigation.mockReturnValue([])
mockResolveWorkspaceGroup.mockResolvedValue(null)
mockIsForkingAvailable.mockResolvedValue(false)
mockHasWorkspaceInboxAccess.mockResolvedValue(false)
mockHasWorkspaceSandboxAccess.mockResolvedValue(false)
mockCanOpenOrganizationSettingsSection.mockResolvedValue(false)
mockIsOrganizationOnEnterprisePlan.mockResolvedValue(false)
mockIsOrganizationSettingsSectionAvailable.mockReturnValue(true)
})

it('redirects an unavailable subscription section to General', async () => {
await expect(WorkspaceSettingsSectionPage(pageProps('billing'))).rejects.toThrow(
'NEXT_REDIRECT:/workspace/workspace-b/settings/general'
)
})

it('redirects a workspace section hidden in the destination workspace to General', async () => {
await expect(WorkspaceSettingsSectionPage(pageProps('secrets'))).rejects.toThrow(
'NEXT_REDIRECT:/workspace/workspace-b/settings/general'
)
})

it('redirects an organization section when the destination has no organization', async () => {
await expect(WorkspaceSettingsSectionPage(pageProps('sessions'))).rejects.toThrow(
'NEXT_REDIRECT:/workspace/workspace-b/settings/general'
)
})

it('keeps unknown settings sections fail-fast', async () => {
await expect(WorkspaceSettingsSectionPage(pageProps('unknown'))).rejects.toThrow(
'NEXT_NOT_FOUND'
)
expect(mockGetWorkspaceHostContext).not.toHaveBeenCalled()
})

it('keeps inaccessible workspaces fail-fast', async () => {
mockGetWorkspaceHostContext.mockResolvedValue(null)

await expect(WorkspaceSettingsSectionPage(pageProps('general'))).rejects.toThrow(
'NEXT_NOT_FOUND'
)
})
})
25 changes: 19 additions & 6 deletions apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ const ORGANIZATION_SECTION_MAP: Partial<Record<SettingsSection, OrganizationSett
'access-control': 'access-control',
'audit-logs': 'audit-logs',
sso: 'sso',
sessions: 'sessions',
'data-retention': 'data-retention',
'data-drains': 'data-drains',
whitelabeling: 'whitelabeling',
Expand All @@ -79,6 +80,14 @@ function parseSection(section: string): SettingsSection | null {
: null
}

/**
* Settings availability varies across workspaces, so a preserved section may
* need to land on the destination workspace's universally available page.
*/
function redirectToGeneralSettings(workspaceId: string): never {
redirect(`/workspace/${workspaceId}/settings/general`)
}

export async function generateMetadata({
params,
}: WorkspaceSettingsSectionPageProps): Promise<Metadata> {
Expand Down Expand Up @@ -131,28 +140,32 @@ export default async function WorkspaceSettingsSectionPage({
sandboxes,
},
})
if (!navigation.some((item) => item.id === workspaceSection)) notFound()
if (!navigation.some((item) => item.id === workspaceSection)) {
redirectToGeneralSettings(workspaceId)
}
}

const organizationSection = ORGANIZATION_SECTION_MAP[parsed]
if (organizationSection) {
if (!isBillingEnabled && (parsed === 'billing' || parsed === 'organization')) {
redirect(`/workspace/${workspaceId}/settings/general`)
redirectToGeneralSettings(workspaceId)
}
if (!hostContext.hostOrganizationId) {
if (parsed !== 'billing' || hostContext.workspace.billedAccountUserId !== session.user.id) {
notFound()
redirectToGeneralSettings(workspaceId)
}
} else {
if (!hostContext.viewer.isHostOrganizationAdmin) notFound()
if (!hostContext.viewer.isHostOrganizationAdmin) {
redirectToGeneralSettings(workspaceId)
}
if (
!(await canOpenOrganizationSettingsSection(
hostContext.hostOrganizationId,
session.user.id,
organizationSection
))
) {
notFound()
redirectToGeneralSettings(workspaceId)
}
const hasEnterprisePlan =
organizationSection !== 'members' &&
Expand All @@ -164,7 +177,7 @@ export default async function WorkspaceSettingsSectionPage({
getOrganizationSettingsFeatures(hasEnterprisePlan)
)
) {
notFound()
redirectToGeneralSettings(workspaceId)
}
}
}
Expand Down
Loading