diff --git a/apps/web/src/components/configurationPolicies/ConfigPolicyCreatePage.test.tsx b/apps/web/src/components/configurationPolicies/ConfigPolicyCreatePage.test.tsx new file mode 100644 index 0000000000..80b9e2e850 --- /dev/null +++ b/apps/web/src/components/configurationPolicies/ConfigPolicyCreatePage.test.tsx @@ -0,0 +1,183 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../stores/auth', () => ({ fetchWithAuth: vi.fn() })); +vi.mock('@/lib/navigation', () => ({ navigateTo: vi.fn() })); + +// Partner scope is detected from the JWT claims (same pattern as AlertTemplateEditor — +// useOrgStore().partners is system-scope-only and empty for real partner users). The +// owner picker also reads currentOrgId/allOrgs/organizations from the org store, so the +// mock applies the selector to a mutable state object each test can override. +const { getJwtClaimsMock, orgState } = vi.hoisted(() => ({ + getJwtClaimsMock: vi.fn<() => { scope: 'system' | 'partner' | 'organization' | null; partnerId: string | null; orgId: string | null }>( + () => ({ scope: 'partner', partnerId: 'p-1', orgId: null }) + ), + orgState: { + current: { + currentOrgId: null as string | null, + allOrgs: true, + organizations: [{ id: 'org-1', name: 'Acme' }, { id: 'org-2', name: 'Beta' }], + }, + }, +})); +vi.mock('@/lib/authScope', async () => { + const actual = await vi.importActual('@/lib/authScope'); + return { ...actual, getJwtClaims: getJwtClaimsMock }; +}); +vi.mock('../../stores/orgStore', () => ({ + useOrgStore: (sel?: (s: typeof orgState.current) => unknown) => (sel ? sel(orgState.current) : orgState.current), +})); + +import ConfigPolicyCreatePage from './ConfigPolicyCreatePage'; +import { fetchWithAuth } from '../../stores/auth'; +import { navigateTo } from '@/lib/navigation'; + +const fetchMock = vi.mocked(fetchWithAuth); +const navMock = vi.mocked(navigateTo); +const json = (payload: unknown, ok = true): Response => + ({ ok, status: ok ? 200 : 400, statusText: 'OK', json: vi.fn().mockResolvedValue(payload) }) as unknown as Response; + +function startNewPolicy() { + render(); + // Step 1 → choose "Configure New" to reach the details form. + fireEvent.click(screen.getByText('Configure New')); +} + +function postBody(): Record { + const post = fetchMock.mock.calls.find( + (c) => c[0] === '/configuration-policies' && (c[1] as RequestInit)?.method === 'POST' + ); + expect(post).toBeTruthy(); + return JSON.parse((post![1] as RequestInit).body as string); +} + +describe('ConfigPolicyCreatePage — owner scope (#1724)', () => { + beforeEach(() => { + vi.clearAllMocks(); + getJwtClaimsMock.mockReturnValue({ scope: 'partner', partnerId: 'p-1', orgId: null }); + orgState.current = { + currentOrgId: null, + allOrgs: true, + organizations: [{ id: 'org-1', name: 'Acme' }, { id: 'org-2', name: 'Beta' }], + }; + fetchMock.mockResolvedValue(json({ id: 'pol-1' }, true)); + }); + + it('shows the owner picker for a partner-scope creator, defaulting to partner-wide in All-orgs scope', () => { + startNewPolicy(); + expect(screen.getByTestId('policy-owner')).toBeInTheDocument(); + expect(screen.getByTestId('policy-owner-partner')).toBeChecked(); + }); + + it('POSTs a partner-wide policy (ownerScope:partner, no orgId) when partner-wide is chosen', async () => { + startNewPolicy(); + fireEvent.change(screen.getByPlaceholderText('e.g. Standard Workstation Policy'), { + target: { value: 'Fleet-wide PAM' }, + }); + fireEvent.click(screen.getByText('Create Policy')); + + await waitFor(() => { + const body = postBody(); + expect(body.ownerScope).toBe('partner'); + expect('orgId' in body).toBe(false); + expect(body.name).toBe('Fleet-wide PAM'); + }); + expect(navMock).toHaveBeenCalledWith('/configuration-policies/pol-1'); + }); + + it('switches to a specific organization and sends orgId without ownerScope', async () => { + startNewPolicy(); + fireEvent.click(screen.getByTestId('policy-owner-org')); + fireEvent.change(screen.getByTestId('policy-owner-org-select'), { target: { value: 'org-2' } }); + fireEvent.change(screen.getByPlaceholderText('e.g. Standard Workstation Policy'), { + target: { value: 'Acme-only policy' }, + }); + fireEvent.click(screen.getByText('Create Policy')); + + await waitFor(() => { + const body = postBody(); + expect(body.orgId).toBe('org-2'); + expect('ownerScope' in body).toBe(false); + }); + }); + + it('defaults to org-scoped when a concrete org is focused, and POSTs that orgId untouched', async () => { + orgState.current = { currentOrgId: 'org-1', allOrgs: false, organizations: orgState.current.organizations }; + startNewPolicy(); + expect(screen.getByTestId('policy-owner-org')).toBeChecked(); + + // Submit without touching the select — the focused org must be sent verbatim. + fireEvent.change(screen.getByPlaceholderText('e.g. Standard Workstation Policy'), { + target: { value: 'Acme default' }, + }); + fireEvent.click(screen.getByText('Create Policy')); + + await waitFor(() => { + const body = postBody(); + expect(body.orgId).toBe('org-1'); + expect('ownerScope' in body).toBe(false); + }); + }); + + it('disables submit (and never POSTs) when "specific organization" is chosen but none selected', () => { + // Partner creator in All-orgs scope: no currentOrgId to silently fall back to. + startNewPolicy(); + fireEvent.click(screen.getByTestId('policy-owner-org')); + fireEvent.change(screen.getByPlaceholderText('e.g. Standard Workstation Policy'), { + target: { value: 'Nameless owner' }, + }); + + expect(screen.getByText('Create Policy').closest('button')).toBeDisabled(); + expect( + fetchMock.mock.calls.some((c) => c[0] === '/configuration-policies' && (c[1] as RequestInit)?.method === 'POST') + ).toBe(false); + }); + + it('does not silently fall back to the focused org when the dropdown is cleared to the placeholder', () => { + // Focused on org-1 (so ownerOrgId initializes to org-1), then user blanks the select. + orgState.current = { currentOrgId: 'org-1', allOrgs: false, organizations: orgState.current.organizations }; + startNewPolicy(); + fireEvent.change(screen.getByTestId('policy-owner-org-select'), { target: { value: '' } }); + fireEvent.change(screen.getByPlaceholderText('e.g. Standard Workstation Policy'), { + target: { value: 'Cleared selection' }, + }); + + // The select visibly shows nothing chosen, so submit must be blocked rather + // than silently POST org-1. + expect(screen.getByText('Create Policy').closest('button')).toBeDisabled(); + }); + + it('blocks Enter-key submit (not just the button) when no org is selected, showing an error', async () => { + const { container } = render(); + fireEvent.click(screen.getByText('Configure New')); + fireEvent.click(screen.getByTestId('policy-owner-org')); + fireEvent.change(screen.getByPlaceholderText('e.g. Standard Workstation Policy'), { + target: { value: 'Enter bypass' }, + }); + // Enter inside a field submits the form regardless of the disabled button. + fireEvent.submit(container.querySelector('form')!); + + await waitFor(() => expect(screen.getByText(/select an organization/i)).toBeInTheDocument()); + expect( + fetchMock.mock.calls.some((c) => c[0] === '/configuration-policies' && (c[1] as RequestInit)?.method === 'POST') + ).toBe(false); + }); + + it('hides the owner picker for an org-scope creator and POSTs orgId only', async () => { + getJwtClaimsMock.mockReturnValue({ scope: 'organization', partnerId: null, orgId: 'org-9' }); + orgState.current = { currentOrgId: 'org-9', allOrgs: false, organizations: [] }; + startNewPolicy(); + expect(screen.queryByTestId('policy-owner')).not.toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText('e.g. Standard Workstation Policy'), { + target: { value: 'Org policy' }, + }); + fireEvent.click(screen.getByText('Create Policy')); + + await waitFor(() => { + const body = postBody(); + expect(body.orgId).toBe('org-9'); + expect('ownerScope' in body).toBe(false); + }); + }); +}); diff --git a/apps/web/src/components/configurationPolicies/ConfigPolicyCreatePage.tsx b/apps/web/src/components/configurationPolicies/ConfigPolicyCreatePage.tsx index ade862a303..5dc185550b 100644 --- a/apps/web/src/components/configurationPolicies/ConfigPolicyCreatePage.tsx +++ b/apps/web/src/components/configurationPolicies/ConfigPolicyCreatePage.tsx @@ -5,6 +5,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { Layers, FilePlus2, Link as LinkIcon } from 'lucide-react'; import { fetchWithAuth } from '../../stores/auth'; import { useOrgStore } from '../../stores/orgStore'; +import { getJwtClaims } from '@/lib/authScope'; import PolicyLinkSelector from './featureTabs/PolicyLinkSelector'; import { navigateTo } from '@/lib/navigation'; import { extractApiError } from '@/lib/apiError'; @@ -19,11 +20,32 @@ const createPolicySchema = z.object({ type CreatePolicyValues = z.infer; type CreateMode = 'new' | 'linked'; +type OwnerScope = 'organization' | 'partner'; + export default function ConfigPolicyCreatePage() { const [error, setError] = useState(); const [mode, setMode] = useState(null); const [linkedPolicyId, setLinkedPolicyId] = useState(null); const currentOrgId = useOrgStore((s) => s.currentOrgId); + const allOrgs = useOrgStore((s) => s.allOrgs); + const organizations = useOrgStore((s) => s.organizations); + + // Ownership axis (#1724). A partner-scope creator may own the policy at their + // own partner (partner-wide / all-orgs, org_id NULL) OR scope it to a single + // org. The partner is ALWAYS derived server-side from the caller's token; we + // only send the intent. Org-scope creators never see this — their policy is + // always owned by their one org. Follows AlertTemplateEditor's JWT-scope + // detection (gate on partner scope from the JWT, not useOrgStore().partners); + // unlike that picker we surface it for any partner-scope creator, not only + // those with more than one org. + const { scope: jwtScope, partnerId: jwtPartnerId } = getJwtClaims(); + const isPartnerScope = jwtScope === 'partner' && !!jwtPartnerId; + // Default to partner-wide when the user is viewing the All-orgs scope (no + // concrete org selected); otherwise default to the org they're focused on. + const [ownerScope, setOwnerScope] = useState( + isPartnerScope && (allOrgs || !currentOrgId) ? 'partner' : 'organization' + ); + const [ownerOrgId, setOwnerOrgId] = useState(currentOrgId ?? ''); const { register, @@ -38,12 +60,33 @@ export default function ConfigPolicyCreatePage() { }, }); + const usePartnerOwner = isPartnerScope && ownerScope === 'partner'; + // Org-scoped owner id. For partner-scope creators the dropdown (`ownerOrgId`) + // is authoritative — do NOT fall back to `currentOrgId`, or clearing the + // select would silently submit the focused org while the UI shows nothing + // chosen. Org-scope creators have no picker, so they always use their own + // current org. + const orgScopedOrgId = isPartnerScope ? ownerOrgId : (currentOrgId ?? ''); + const onSubmit = async (values: CreatePolicyValues) => { try { setError(undefined); + // Guard the org-scoped path here too (not just the disabled button) so the + // Enter key can't bypass it into a `{ orgId: '' }` POST with an opaque + // server 400. Partner-wide needs no org — the server derives the partner. + if (!usePartnerOwner && !orgScopedOrgId) { + setError('Select an organization for this policy.'); + return; + } + // Partner-wide: send ownerScope only — the server derives the partner from + // the caller's token and ignores any client-supplied org/partner id. Org- + // scoped: send the concrete org id (the classic shape). + const body = usePartnerOwner + ? { ...values, ownerScope: 'partner' as const } + : { ...values, orgId: orgScopedOrgId }; const response = await fetchWithAuth('/configuration-policies', { method: 'POST', - body: JSON.stringify({ ...values, orgId: currentOrgId }), + body: JSON.stringify(body), }); if (!response.ok) { @@ -194,6 +237,59 @@ export default function ConfigPolicyCreatePage() { + + {isPartnerScope && ( +
+ Apply to + + + {ownerScope === 'organization' && ( +
+ + +
+ )} + {ownerScope === 'partner' && ( +

+ Applies to every organization under your partner. Backup settings aren't available on + partner-wide policies. +

+ )} +
+ )} @@ -214,7 +310,7 @@ export default function ConfigPolicyCreatePage() {