Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { render, screen } from '@testing-library/react';
import { Formik } from 'formik';

import type { ClientPolicy } from 'types/policy.proto';

import PolicyCriteriaFieldInput from './PolicyCriteriaFieldInput';
import type { TextDescriptor } from './policyCriteriaDescriptors';

/**
* Minimal ClientPolicy stub satisfying the type requirements for Formik context.
* Only the shape matters here -- the component under test reads `value` from `useField`,
* not from the top-level form values.
*/
const emptyPolicy: ClientPolicy = {
id: '',
name: '',
description: '',
severity: 'LOW_SEVERITY',
disabled: false,
lifecycleStages: [],
notifiers: [],
lastUpdated: null,
eventSource: 'NOT_APPLICABLE',
isDefault: false,
source: 'IMPERATIVE',
rationale: '',
remediation: '',
categories: [],
exclusions: [],
scope: [],
enforcementActions: [],
SORTName: '',
SORTLifecycleStage: '',
SORTEnforcement: false,
policyVersion: '',
mitreAttackVectors: [],
criteriaLocked: false,
mitreVectorsLocked: false,
excludedImageNames: [],
excludedDeploymentScopes: [],
serverPolicySections: [],
policySections: [],
};

const fieldName = 'policySections[0].policyGroups[0].values[0]';

function makeTextDescriptor(
overrides: Partial<Pick<TextDescriptor, 'validate' | 'warn' | 'helperText'>> = {}
): TextDescriptor {
return {
type: 'text',
name: 'Test Field',
shortName: 'Test',
category: 'Test',
canBooleanLogic: false,
lifecycleStages: ['RUNTIME'],
...overrides,
};
}

function renderWithFormik(descriptor: TextDescriptor, fieldValue: string) {
const initialValues: ClientPolicy = {
...emptyPolicy,
policySections: [
{
sectionName: 'test',
policyGroups: [
{
fieldName: 'Test Field',
booleanOperator: 'OR',
negate: false,
values: [{ value: fieldValue }],
},
],
},
],
};

return render(
<Formik initialValues={initialValues} onSubmit={() => {}}>
<PolicyCriteriaFieldInput

Check failure on line 81 in ui/apps/platform/src/Containers/Policies/Wizard/Step3/PolicyCriteriaFieldInput.test.tsx

View workflow job for this annotation

GitHub Actions / style-check

Replace `⏎················descriptor={descriptor}⏎················name={fieldName}⏎················readOnly={false}⏎···········` with `·descriptor={descriptor}·name={fieldName}·readOnly={false}`
descriptor={descriptor}
name={fieldName}
readOnly={false}
/>
</Formik>
);
}

describe('PolicyCriteriaFieldInput warning rendering', () => {
it('should render a warning when warn returns a message and validate is absent', () => {
const warningText = 'This pattern is too broad';
const descriptor = makeTextDescriptor({
warn: () => warningText,
});

renderWithFormik(descriptor, '/**');

expect(screen.getByText(warningText)).toBeInTheDocument();
// HelperTextItem renders with a `pf-m-warning` class when variant="warning"
const helperTextItem = screen.getByText(warningText).closest('.pf-v6-c-helper-text__item');

Check failure on line 101 in ui/apps/platform/src/Containers/Policies/Wizard/Step3/PolicyCriteriaFieldInput.test.tsx

View workflow job for this annotation

GitHub Actions / style-check

Avoid direct Node access. Prefer using the methods from Testing Library

Check failure on line 101 in ui/apps/platform/src/Containers/Policies/Wizard/Step3/PolicyCriteriaFieldInput.test.tsx

View workflow job for this annotation

GitHub Actions / style-check

Avoid direct Node access. Prefer using the methods from Testing Library
expect(helperTextItem).toHaveClass('pf-m-warning');
});

it('should render an error and suppress the warning when validate returns an error', () => {
const errorText = 'Invalid file path';
const warningText = 'This pattern is too broad';
const descriptor = makeTextDescriptor({
validate: () => errorText,
warn: () => warningText,
});

renderWithFormik(descriptor, '/**');

expect(screen.getByText(errorText)).toBeInTheDocument();
expect(screen.queryByText(warningText)).not.toBeInTheDocument();
const helperTextItem = screen.getByText(errorText).closest('.pf-v6-c-helper-text__item');

Check failure on line 117 in ui/apps/platform/src/Containers/Policies/Wizard/Step3/PolicyCriteriaFieldInput.test.tsx

View workflow job for this annotation

GitHub Actions / style-check

Avoid direct Node access. Prefer using the methods from Testing Library

Check failure on line 117 in ui/apps/platform/src/Containers/Policies/Wizard/Step3/PolicyCriteriaFieldInput.test.tsx

View workflow job for this annotation

GitHub Actions / style-check

Avoid direct Node access. Prefer using the methods from Testing Library
expect(helperTextItem).toHaveClass('pf-m-error');
});

it('should render default helperText when neither validate nor warn fires', () => {
const helperText = 'Enter a file path';
const descriptor = makeTextDescriptor({
validate: () => undefined,
warn: () => undefined,
helperText,
});

renderWithFormik(descriptor, '/etc/passwd');

expect(screen.getByText(helperText)).toBeInTheDocument();
const helperTextItem = screen.getByText(helperText).closest('.pf-v6-c-helper-text__item');

Check failure on line 132 in ui/apps/platform/src/Containers/Policies/Wizard/Step3/PolicyCriteriaFieldInput.test.tsx

View workflow job for this annotation

GitHub Actions / style-check

Avoid direct Node access. Prefer using the methods from Testing Library

Check failure on line 132 in ui/apps/platform/src/Containers/Policies/Wizard/Step3/PolicyCriteriaFieldInput.test.tsx

View workflow job for this annotation

GitHub Actions / style-check

Avoid direct Node access. Prefer using the methods from Testing Library
expect(helperTextItem).not.toHaveClass('pf-m-warning');
expect(helperTextItem).not.toHaveClass('pf-m-error');
});

it('should render no helper text when all feedback sources are absent', () => {
const descriptor = makeTextDescriptor({
validate: () => undefined,
warn: () => undefined,
});

renderWithFormik(descriptor, '/some/path');

const input = screen.getByTestId('policy-criteria-value-text-input');
expect(input).toBeInTheDocument();
// No FormHelperText should be rendered
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ function PolicyCriteriaFieldInput({
// value.value is always a string for 'text' type descriptors
const validationError = descriptor.validate?.(String(value.value));
const showError = Boolean(validationError);
const warningMessage = !showError ? descriptor.warn?.(String(value.value)) : undefined;
const showWarning = Boolean(warningMessage);

const feedbackVariant = showError ? 'error' : showWarning ? 'warning' : 'default';
const feedbackMessage = validationError ?? warningMessage ?? descriptor.helperText;

return (
<Flex grow={{ default: 'grow' }}>
Expand All @@ -70,13 +75,13 @@ function PolicyCriteriaFieldInput({
onChange={(_event, val) => handleChangeValue(val)}
data-testid="policy-criteria-value-text-input"
placeholder={descriptor.placeholder || ''}
validated={showError ? 'error' : 'default'}
validated={feedbackVariant}
/>
{(descriptor.helperText || showError) && (
{feedbackMessage && (
<FormHelperText>
<HelperText>
<HelperTextItem variant={showError ? 'error' : 'default'}>
{showError ? validationError : descriptor.helperText}
<HelperText isLiveRegion={showError || showWarning}>
<HelperTextItem variant={feedbackVariant}>
{feedbackMessage}
</HelperTextItem>
</HelperText>
</FormHelperText>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
nodeEventDescriptor,
policyCriteriaDescriptors,
validateFilePath,
warnBroadFilePath,
} from './policyCriteriaDescriptors';

// Enforce consistency of whicheverName properties in policy criteria descriptors.
Expand Down Expand Up @@ -97,6 +98,76 @@ describe('validateFilePath', () => {
});
});

describe('warnBroadFilePath', () => {
it('should return undefined for an empty string', () => {
expect(warnBroadFilePath('')).toBeUndefined();
});

it('should return undefined for a whitespace-only string', () => {
expect(warnBroadFilePath(' ')).toBeUndefined();
});

it('should warn for /** (root catch-all)', () => {
expect(warnBroadFilePath('/**')).toContain('every file on the system');
});

it('should warn for /* (root catch-all)', () => {
expect(warnBroadFilePath('/*')).toBeDefined();
});

it('should not warn for /**/foo (scoped recursive search)', () => {
expect(warnBroadFilePath('/**/foo')).toBeUndefined();
});

it('should warn for /*/bar (root-level single-level search)', () => {
expect(warnBroadFilePath('/*/bar')).toContain('immediate subdirectories');
});

it('should warn for /tmp/**', () => {
expect(warnBroadFilePath('/tmp/**')).toContain('temporary file');
});

it('should warn for /proc/*', () => {
expect(warnBroadFilePath('/proc/*')).toBeDefined();
});

it('should warn for /sys/**', () => {
expect(warnBroadFilePath('/sys/**')).toBeDefined();
});

it('should warn for /var/log/**', () => {
expect(warnBroadFilePath('/var/log/**')).toBeDefined();
});

it('should not warn for /etc/passwd (specific safe path)', () => {
expect(warnBroadFilePath('/etc/passwd')).toBeUndefined();
});

it('should not warn for / (root path, not a glob)', () => {
expect(warnBroadFilePath('/')).toBeUndefined();
});

it('should not warn for /tmp (exact path, no glob)', () => {
expect(warnBroadFilePath('/tmp')).toBeUndefined();
});

it('should not warn for /tmp/specific.txt (exact path under high-churn)', () => {
expect(warnBroadFilePath('/tmp/specific.txt')).toBeUndefined();
});

it('should not warn for /proc/1/status (specific path under high-churn)', () => {
expect(warnBroadFilePath('/proc/1/status')).toBeUndefined();
});

it('should not warn for /home/**/.ssh/id_* (scoped pattern)', () => {
expect(warnBroadFilePath('/home/**/.ssh/id_*')).toBeUndefined();
});

it('should handle leading/trailing whitespace', () => {
expect(warnBroadFilePath(' /** ')).toBeDefined();
});
});

describe('policyCriteriaDescriptors', () => {
[...auditLogDescriptor, ...policyCriteriaDescriptors, ...nodeEventDescriptor].forEach(
(descriptor) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,44 @@ export function validateFilePath(value: string): string | undefined {
return undefined;
}

const highChurnPrefixes: Record<string, string> = {
'/tmp': 'temporary file',
'/proc': 'system',
'/sys': 'system',
'/var/log': 'log file',
};

/**
* Returns a warning message when a file path glob pattern is structurally too broad,
* such as root-level catch-alls or globs under high-churn directories.
*/
export function warnBroadFilePath(value: string): string | undefined {
const trimmed = value.trim();
if (trimmed.length === 0) {
return undefined;
}

// Root-level catch-all without additional path segments: /**, /*
if (trimmed === '/**' || trimmed === '/*') {
return 'This pattern matches every file on the system and will generate extreme alert volume. Consider narrowing to a specific directory like /etc/**.';
}

// Root-level single-level glob with trailing path: /*/bar
if (trimmed.startsWith('/*/')) {
return 'This pattern matches all immediate subdirectories of root. Consider scoping to a specific directory.';
}

// High-churn directories with glob wildcards
const matchedPrefix = Object.keys(highChurnPrefixes).find(
(prefix) => trimmed.startsWith(`${prefix}/`) && trimmed.includes('*')
);
if (matchedPrefix) {
return `Patterns under ${matchedPrefix} typically generate very high alert volume due to frequent ${highChurnPrefixes[matchedPrefix]} activity.`;
}

return undefined;
}

const equalityOptions: DescriptorOption[] = [
{ label: 'Is greater than', value: '>' },
{
Expand Down Expand Up @@ -390,6 +428,7 @@ export type TextDescriptor = {
placeholder?: string;
helperText?: string;
validate?: (value: string) => string | undefined;
warn?: (value: string) => string | undefined;
} & BaseDescriptor &
DescriptorCanBoolean &
DescriptorCanNegate;
Expand Down Expand Up @@ -1532,6 +1571,7 @@ export const policyCriteriaDescriptors: Descriptor[] = [
placeholder: '/home/**/.ssh/id_*',
helperText: 'Enter an absolute file path. Supports glob patterns.',
validate: validateFilePath,
warn: warnBroadFilePath,
canBooleanLogic: false,
lifecycleStages: ['RUNTIME'],
featureFlagDependency: ['ROX_SENSITIVE_FILE_ACTIVITY'],
Expand Down Expand Up @@ -1679,6 +1719,7 @@ export const nodeEventDescriptor: Descriptor[] = [
placeholder: '/home/**/.ssh/id_*',
helperText: 'Enter an absolute file path. Supports glob patterns.',
validate: validateFilePath,
warn: warnBroadFilePath,
canBooleanLogic: false,
lifecycleStages: ['RUNTIME'],
},
Expand Down
Loading