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
Original file line number Diff line number Diff line change
@@ -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<typeof import('@/lib/authScope')>('@/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(<ConfigPolicyCreatePage />);
// Step 1 → choose "Configure New" to reach the details form.
fireEvent.click(screen.getByText('Configure New'));
}

function postBody(): Record<string, unknown> {
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(<ConfigPolicyCreatePage />);
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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -19,11 +20,32 @@ const createPolicySchema = z.object({
type CreatePolicyValues = z.infer<typeof createPolicySchema>;
type CreateMode = 'new' | 'linked';

type OwnerScope = 'organization' | 'partner';

export default function ConfigPolicyCreatePage() {
const [error, setError] = useState<string>();
const [mode, setMode] = useState<CreateMode | null>(null);
const [linkedPolicyId, setLinkedPolicyId] = useState<string | null>(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<OwnerScope>(
isPartnerScope && (allOrgs || !currentOrgId) ? 'partner' : 'organization'
);
const [ownerOrgId, setOwnerOrgId] = useState<string>(currentOrgId ?? '');

const {
register,
Expand All @@ -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) {
Expand Down Expand Up @@ -194,6 +237,59 @@ export default function ConfigPolicyCreatePage() {
<option value="inactive">Inactive</option>
</select>
</div>

{isPartnerScope && (
<fieldset className="space-y-2 rounded-md border p-4" data-testid="policy-owner">
<legend className="px-1 text-xs font-medium uppercase text-muted-foreground">Apply to</legend>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="ownerScope"
value="partner"
checked={ownerScope === 'partner'}
onChange={() => setOwnerScope('partner')}
data-testid="policy-owner-partner"
/>
All organizations <span className="text-muted-foreground">(partner-wide)</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="ownerScope"
value="organization"
checked={ownerScope === 'organization'}
onChange={() => setOwnerScope('organization')}
data-testid="policy-owner-org"
/>
A specific organization
</label>
{ownerScope === 'organization' && (
<div className="mt-2 space-y-1 pl-6">
<label className="text-xs font-medium text-muted-foreground" htmlFor="policy-owner-org-select">
Organization
</label>
<select
id="policy-owner-org-select"
value={ownerOrgId}
onChange={(e) => setOwnerOrgId(e.target.value)}
data-testid="policy-owner-org-select"
className="h-9 w-full rounded-md border bg-background px-3 text-sm focus:outline-hidden focus:ring-2 focus:ring-ring sm:w-72"
>
<option value="">Select an organization</option>
{organizations.map((org) => (
<option key={org.id} value={org.id}>{org.name}</option>
))}
</select>
</div>
)}
{ownerScope === 'partner' && (
<p className="pl-6 text-xs text-muted-foreground">
Applies to every organization under your partner. Backup settings aren&apos;t available on
partner-wide policies.
</p>
)}
</fieldset>
)}
</div>
</div>

Expand All @@ -214,7 +310,7 @@ export default function ConfigPolicyCreatePage() {
</a>
<button
type="submit"
disabled={isSubmitting || (mode === 'linked' && !linkedPolicyId)}
disabled={isSubmitting || (mode === 'linked' && !linkedPolicyId) || (!usePartnerOwner && !orgScopedOrgId)}
className="h-10 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition hover:opacity-90 disabled:opacity-50"
>
{isSubmitting ? 'Creating...' : 'Create Policy'}
Expand Down
Loading