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
121 changes: 98 additions & 23 deletions src/hooks/__tests__/use-permissions.test.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import type { ReactNode } from 'react';

import '@testing-library/jest-dom';
import { renderHook } from '@testing-library/react';

import { usePlans } from '@/components/providers/SelectedPlanProvider';
import { UserProfileContext, usePermissions } from '@/hooks/use-user-profile';
import type { ProfileQuery } from '@/types/__generated__/graphql';

// These packages use ESM-only builds that Jest (CJS mode) cannot parse.
// Mock the whole module to keep the import chain CJS-compatible.
jest.mock('next-auth/react', () => ({ useSession: jest.fn(() => ({ status: 'authenticated' })) }));
jest.mock('@/components/providers/SelectedPlanProvider', () => ({ usePlans: jest.fn() }));

import type { ReactNode } from 'react';
import { renderHook } from '@testing-library/react';
import type { ProfileQuery } from '@/types/__generated__/graphql';
import { UserProfileContext, usePermissions } from '@/hooks/use-user-profile';
import { usePlans } from '@/components/providers/SelectedPlanProvider';

const mockUsePlans = usePlans as jest.MockedFunction<typeof usePlans>;

type SelectedPlan = NonNullable<ReturnType<typeof usePlans>['selectedPlan']>;
Expand All @@ -30,12 +31,14 @@ function makePlan(overrides: Partial<SelectedPlan> = {}): SelectedPlan {
};
}

function makeProfile(overrides: {
change?: boolean;
creatableRelatedModels?: string[];
userRoles?: string[];
configs?: Array<{ id: string; change?: boolean; delete?: boolean }>;
} = {}): ProfileQuery {
function makeProfile(
overrides: {
change?: boolean;
creatableRelatedModels?: string[];
userRoles?: string[];
configs?: Array<{ id: string; change?: boolean; delete?: boolean }>;
} = {}
): ProfileQuery {
return {
__typename: 'Query',
me: null,
Expand Down Expand Up @@ -86,20 +89,67 @@ describe('usePermissions', () => {
});

describe('edit permission', () => {
it('returns true when framework.userPermissions.change is true, even if plan is locked', () => {
it('returns true when the selected config userPermissions.change is true, even if plan is locked', () => {
const plan = makePlan({ isLocked: true });
mockUsePlans.mockReturnValue({ selectedPlanId: 'plan-1', selectedPlan: plan, allPlans: [plan], setSelectedPlanId: jest.fn() });
mockUsePlans.mockReturnValue({
selectedPlanId: 'plan-1',
selectedPlan: plan,
allPlans: [plan],
setSelectedPlanId: jest.fn(),
});
const { result } = renderHook(() => usePermissions(), {
wrapper: makeWrapper(false, makeProfile({ change: true })),
wrapper: makeWrapper(false, makeProfile({ configs: [{ id: 'plan-1', change: true }] })),
});
expect(result.current.edit).toBe(true);
});

it('returns false when framework.userPermissions.change is false', () => {
it('returns false when the selected config userPermissions.change is false', () => {
const plan = makePlan();

mockUsePlans.mockReturnValue({
selectedPlanId: 'plan-1',
selectedPlan: plan,
allPlans: [plan],
setSelectedPlanId: jest.fn(),
});

const { result } = renderHook(() => usePermissions(), {
wrapper: makeWrapper(false, makeProfile({ configs: [{ id: 'plan-1', change: false }] })),
});

expect(result.current.edit).toBe(false);
});

it('returns false when framework-level change is true but selected config change is false', () => {
const plan = makePlan();

mockUsePlans.mockReturnValue({
selectedPlanId: 'plan-1',
selectedPlan: plan,
allPlans: [plan],
setSelectedPlanId: jest.fn(),
});

const { result } = renderHook(() => usePermissions(), {
wrapper: makeWrapper(
false,
makeProfile({ change: true, configs: [{ id: 'plan-1', change: false }] })
),
});

expect(result.current.edit).toBe(false);
});

it('returns false when there is no matching config for the selected plan', () => {
const plan = makePlan();
mockUsePlans.mockReturnValue({ selectedPlanId: 'plan-1', selectedPlan: plan, allPlans: [plan], setSelectedPlanId: jest.fn() });
mockUsePlans.mockReturnValue({
selectedPlanId: 'plan-1',
selectedPlan: plan,
allPlans: [plan],
setSelectedPlanId: jest.fn(),
});
const { result } = renderHook(() => usePermissions(), {
wrapper: makeWrapper(false, makeProfile({ change: false })),
wrapper: makeWrapper(false, makeProfile({ configs: [{ id: 'other-plan', change: true }] })),
});
expect(result.current.edit).toBe(false);
});
Expand All @@ -108,7 +158,12 @@ describe('usePermissions', () => {
describe('isLocked', () => {
it('returns true when the selected plan has isLocked:true', () => {
const plan = makePlan({ isLocked: true });
mockUsePlans.mockReturnValue({ selectedPlanId: 'plan-1', selectedPlan: plan, allPlans: [plan], setSelectedPlanId: jest.fn() });
mockUsePlans.mockReturnValue({
selectedPlanId: 'plan-1',
selectedPlan: plan,
allPlans: [plan],
setSelectedPlanId: jest.fn(),
});
const { result } = renderHook(() => usePermissions(), {
wrapper: makeWrapper(false, makeProfile()),
});
Expand All @@ -117,7 +172,12 @@ describe('usePermissions', () => {

it('returns false when the selected plan has isLocked:false', () => {
const plan = makePlan({ isLocked: false });
mockUsePlans.mockReturnValue({ selectedPlanId: 'plan-1', selectedPlan: plan, allPlans: [plan], setSelectedPlanId: jest.fn() });
mockUsePlans.mockReturnValue({
selectedPlanId: 'plan-1',
selectedPlan: plan,
allPlans: [plan],
setSelectedPlanId: jest.fn(),
});
const { result } = renderHook(() => usePermissions(), {
wrapper: makeWrapper(false, makeProfile()),
});
Expand All @@ -135,7 +195,12 @@ describe('usePermissions', () => {
describe('isAdmin', () => {
it('returns true for a framework-admin user', () => {
const plan = makePlan();
mockUsePlans.mockReturnValue({ selectedPlanId: 'plan-1', selectedPlan: plan, allPlans: [plan], setSelectedPlanId: jest.fn() });
mockUsePlans.mockReturnValue({
selectedPlanId: 'plan-1',
selectedPlan: plan,
allPlans: [plan],
setSelectedPlanId: jest.fn(),
});
const { result } = renderHook(() => usePermissions(), {
wrapper: makeWrapper(false, makeProfile({ userRoles: ['framework-admin'] })),
});
Expand All @@ -144,7 +209,12 @@ describe('usePermissions', () => {

it('returns true for a user with config-level delete permission (instance-admin)', () => {
const plan = makePlan();
mockUsePlans.mockReturnValue({ selectedPlanId: 'plan-1', selectedPlan: plan, allPlans: [plan], setSelectedPlanId: jest.fn() });
mockUsePlans.mockReturnValue({
selectedPlanId: 'plan-1',
selectedPlan: plan,
allPlans: [plan],
setSelectedPlanId: jest.fn(),
});
const { result } = renderHook(() => usePermissions(), {
wrapper: makeWrapper(false, makeProfile({ configs: [{ id: 'plan-1', delete: true }] })),
});
Expand All @@ -153,7 +223,12 @@ describe('usePermissions', () => {

it('returns false when neither framework-admin nor config delete permission', () => {
const plan = makePlan();
mockUsePlans.mockReturnValue({ selectedPlanId: 'plan-1', selectedPlan: plan, allPlans: [plan], setSelectedPlanId: jest.fn() });
mockUsePlans.mockReturnValue({
selectedPlanId: 'plan-1',
selectedPlan: plan,
allPlans: [plan],
setSelectedPlanId: jest.fn(),
});
const { result } = renderHook(() => usePermissions(), {
wrapper: makeWrapper(false, makeProfile({ configs: [{ id: 'plan-1', delete: false }] })),
});
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/use-user-profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export function usePermissions() {
isAdmin: isFrameworkAdmin || canDelete,
isLoading: loading,
create: canCreate,
edit: profile?.framework?.userPermissions?.change ?? false,
edit: frameworkConfigPermissions?.change ?? false,
delete: canDelete,
isLocked,
};
Expand Down
Loading