mirror of
https://github.com/AdguardTeam/AdGuardHome.git
synced 2026-10-12 02:01:26 +00:00
fix clients
This commit is contained in:
@@ -13,9 +13,8 @@ describe('Identifiers', () => {
|
||||
render(() => <Identifiers />);
|
||||
const input = document.querySelector('input[type="text"]') as HTMLInputElement;
|
||||
|
||||
// Simulate typing + blur-commit (the Input listens to the native `change`
|
||||
// event, which fires when the field loses focus).
|
||||
fireEvent.change(input, { target: { value: '1.2.3.4' } });
|
||||
// Simulate typing — fireEvent.input triggers onInput, which calls handleChange.
|
||||
fireEvent.input(input, { target: { value: '1.2.3.4' } });
|
||||
|
||||
expect(input.value).toBe('1.2.3.4');
|
||||
expect(clientFormState.ids).toEqual(['1.2.3.4']);
|
||||
@@ -35,7 +34,7 @@ describe('Identifiers', () => {
|
||||
it('keeps existing values when adding a new row', () => {
|
||||
render(() => <Identifiers />);
|
||||
const first = document.querySelector('input[type="text"]') as HTMLInputElement;
|
||||
fireEvent.change(first, { target: { value: '1.2.3.4' } });
|
||||
fireEvent.input(first, { target: { value: '1.2.3.4' } });
|
||||
|
||||
fireEvent.click(screen.getByTestId('client-form-add-identifier'));
|
||||
|
||||
@@ -63,10 +62,28 @@ describe('Identifiers', () => {
|
||||
render(() => <Identifiers />);
|
||||
const input = document.querySelector('input[type="text"]') as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: 'not-a-valid-id' } });
|
||||
fireEvent.input(input, { target: { value: 'not-a-valid-id' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
// The error message element is rendered by the Input when there is an error.
|
||||
expect(input.value).toBe('not-a-valid-id');
|
||||
});
|
||||
|
||||
it('clears the local error when the user types in the field', () => {
|
||||
render(() => <Identifiers />);
|
||||
const input = document.querySelector('input[type="text"]') as HTMLInputElement;
|
||||
|
||||
// Trigger a validation error on blur.
|
||||
fireEvent.input(input, { target: { value: 'bad!!!' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
// Now type a valid value — fireEvent.input triggers onInput, which calls
|
||||
// handleChange → clears the local error.
|
||||
fireEvent.input(input, { target: { value: '1.2.3.4' } });
|
||||
|
||||
// The Input component only renders the error div when errorMessage is truthy.
|
||||
// Check that the wrapper does not have the error modifier class.
|
||||
const wrapper = input.closest('[class*="inputWrapper"]');
|
||||
expect(wrapper?.className).not.toContain('error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
// vi.hoisted ensures the mock is available before module evaluation.
|
||||
const dashboardMocks = vi.hoisted(() => ({
|
||||
getClients: vi.fn(),
|
||||
dashboardState: { clients: [] as any[] },
|
||||
}));
|
||||
|
||||
vi.mock('panel/stores/dashboard', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('panel/stores/dashboard')>('panel/stores/dashboard');
|
||||
return {
|
||||
...actual,
|
||||
getClients: dashboardMocks.getClients,
|
||||
get dashboardState() {
|
||||
return dashboardMocks.dashboardState;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
initClientForm,
|
||||
updateClientFormField,
|
||||
clearClientForm,
|
||||
clientFormState,
|
||||
saveClient,
|
||||
} from 'panel/stores/clientForm';
|
||||
|
||||
describe('clientForm store', () => {
|
||||
@@ -66,3 +86,34 @@ describe('clientForm store', () => {
|
||||
expect(clientFormState.mode).toBe('add');
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveClient cross-client validation', () => {
|
||||
beforeEach(() => {
|
||||
clearClientForm();
|
||||
dashboardMocks.dashboardState.clients = [];
|
||||
});
|
||||
|
||||
it('rejects an identifier that already belongs to another client', async () => {
|
||||
// Set up the mocked dashboard store to include a client with a known ID.
|
||||
dashboardMocks.dashboardState.clients = [{ name: 'Other', ids: ['192.168.1.50'] } as any];
|
||||
|
||||
updateClientFormField({ field: 'name', value: 'New Client' });
|
||||
updateClientFormField({ field: 'ids', value: ['192.168.1.50'] });
|
||||
|
||||
const result = await saveClient();
|
||||
expect(result).toBe(false);
|
||||
expect(clientFormState.formErrors.ids).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects cache size of 0 when cache is enabled', async () => {
|
||||
clearClientForm();
|
||||
updateClientFormField({ field: 'name', value: 'Test' });
|
||||
updateClientFormField({ field: 'ids', value: ['192.168.1.1'] });
|
||||
updateClientFormField({ field: 'upstreams_cache_enabled', value: true });
|
||||
updateClientFormField({ field: 'upstreams_cache_size', value: 0 });
|
||||
|
||||
const result = await saveClient();
|
||||
expect(result).toBe(false);
|
||||
expect(clientFormState.formErrors.upstreams_cache_size).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
validateMaxValue,
|
||||
validateBetween,
|
||||
validateMinValue,
|
||||
validateCacheSize,
|
||||
} from 'panel/helpers/validators';
|
||||
|
||||
describe('validateIdentifier', () => {
|
||||
@@ -395,3 +396,28 @@ describe('numeric range validators', () => {
|
||||
expect(validateMinValue(10, 1)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateCacheSize', () => {
|
||||
it('returns undefined when cache is disabled', () => {
|
||||
expect(validateCacheSize(0, false)).toBeUndefined();
|
||||
expect(validateCacheSize(999999999999, false)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns error for 0 when enabled', () => {
|
||||
const result = validateCacheSize(0, true);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns undefined for a valid size when enabled', () => {
|
||||
expect(validateCacheSize(1000, true)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns error for value exceeding UINT32_MAX', () => {
|
||||
const result = validateCacheSize(4294967296, true);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns undefined at UINT32_MAX boundary', () => {
|
||||
expect(validateCacheSize(4294967295, true)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
setFormErrors,
|
||||
} from 'panel/stores/clientForm';
|
||||
import { dashboardState, getClients } from 'panel/stores/dashboard';
|
||||
import { validateUpstreams } from 'panel/helpers/validators';
|
||||
import { validateUpstreams, validateCacheSize } from 'panel/helpers/validators';
|
||||
import { RoutePath, Paths } from 'panel/components/Routes/Paths';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
@@ -126,6 +126,16 @@ export const AddClient = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleCacheSizeBlur = () => {
|
||||
if (!clientFormState.upstreams_cache_enabled) return;
|
||||
const err = validateCacheSize(clientFormState.upstreams_cache_size, true);
|
||||
if (err) {
|
||||
// Merge with existing errors rather than replacing — preserves sibling
|
||||
// errors like name and ids that were already set.
|
||||
setFormErrors({ ...clientFormState.formErrors, upstreams_cache_size: err });
|
||||
}
|
||||
};
|
||||
|
||||
const tagOptions = createMemo(() =>
|
||||
(dashboardState.supportedTags || []).map((tag: string) => ({
|
||||
label: tag,
|
||||
@@ -176,6 +186,12 @@ export const AddClient = () => {
|
||||
value: (e.target as HTMLInputElement).value,
|
||||
})
|
||||
}
|
||||
onInput={(e: Event) =>
|
||||
updateClientFormField({
|
||||
field: 'name',
|
||||
value: (e.target as HTMLInputElement).value,
|
||||
})
|
||||
}
|
||||
placeholder={intl.getMessage('clients_add_default_name')}
|
||||
label={intl.getMessage('clients_add_name')}
|
||||
size="large"
|
||||
@@ -319,9 +335,17 @@ export const AddClient = () => {
|
||||
value: Number((e.target as HTMLInputElement).value) || 0,
|
||||
})
|
||||
}
|
||||
onBlur={handleCacheSizeBlur}
|
||||
placeholder={intl.getMessage('clients_dns_cache_size_placeholder')}
|
||||
label={intl.getMessage('clients_dns_cache_size')}
|
||||
size="large"
|
||||
error={!!clientFormState.formErrors.upstreams_cache_size}
|
||||
errorMessage={
|
||||
typeof clientFormState.formErrors.upstreams_cache_size ===
|
||||
'string'
|
||||
? clientFormState.formErrors.upstreams_cache_size
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
</SwitchGroup>
|
||||
|
||||
@@ -3,9 +3,11 @@ import cn from 'clsx';
|
||||
import intl from 'panel/common/intl';
|
||||
import { Input } from 'panel/common/controls/Input';
|
||||
import { Icon } from 'panel/common/ui/Icon';
|
||||
import type { Client } from 'panel/initialState';
|
||||
import { clientFormState, updateClientFormField } from 'panel/stores/clientForm';
|
||||
import { dashboardState } from 'panel/stores/dashboard';
|
||||
import {
|
||||
clientFormState,
|
||||
updateClientFormField,
|
||||
computeExistingClientIds,
|
||||
} from 'panel/stores/clientForm';
|
||||
import { validateIdentifier } from 'panel/helpers/validators';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
@@ -30,16 +32,13 @@ export const Identifiers = () => {
|
||||
const formErrors = clientFormState.formErrors;
|
||||
if (Array.isArray(formErrors.ids)) {
|
||||
setErrors(formErrors.ids);
|
||||
} else {
|
||||
// Store errors were cleared — clear stale local errors too.
|
||||
setErrors((prev) => prev.map((): undefined => undefined));
|
||||
}
|
||||
});
|
||||
|
||||
const existingClientIds = createMemo(() => {
|
||||
const clients: Client[] = dashboardState.clients || [];
|
||||
const isEdit = clientFormState.mode === 'edit';
|
||||
return clients
|
||||
.filter((c) => !isEdit || c.name !== clientFormState.originalName)
|
||||
.flatMap((c) => c.ids);
|
||||
});
|
||||
const existingClientIds = createMemo(() => computeExistingClientIds());
|
||||
|
||||
const handleAdd = () => {
|
||||
updateClientFormField('ids', [...clientFormState.ids, '']);
|
||||
@@ -56,6 +55,12 @@ export const Identifiers = () => {
|
||||
const newIds = [...clientFormState.ids];
|
||||
newIds[index] = value;
|
||||
updateClientFormField('ids', newIds);
|
||||
// Clear the local error for this index — re-validated on blur.
|
||||
setErrors((prev) => {
|
||||
const next = [...prev];
|
||||
next[index] = undefined;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleBlur = (index: number) => {
|
||||
@@ -100,6 +105,9 @@ export const Identifiers = () => {
|
||||
onChange={(e: Event) =>
|
||||
handleChange(index, (e.target as HTMLInputElement).value)
|
||||
}
|
||||
onInput={(e: Event) =>
|
||||
handleChange(index, (e.target as HTMLInputElement).value)
|
||||
}
|
||||
onBlur={() => handleBlur(index)}
|
||||
placeholder={intl.getMessage('clients_identifier_format_error')}
|
||||
error={!!activeError()}
|
||||
|
||||
+1
@@ -101,6 +101,7 @@
|
||||
flex-shrink: 0;
|
||||
color: var(--default-gray-icons);
|
||||
transition: color var(--t2);
|
||||
outline: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--default-main-text);
|
||||
|
||||
+13
-9
@@ -54,12 +54,15 @@ export const PersistentClientsTable = (props: Props) => {
|
||||
text: intl.getMessage('client_identifier'),
|
||||
className: s.headerCell,
|
||||
},
|
||||
accessor: (row: Client) => row.ids.join(','),
|
||||
accessor: (row: Client) => row.ids.filter((id) => id.trim() !== '').join(','),
|
||||
sortable: true,
|
||||
render: (_value: string, row: Client) => {
|
||||
const { ids } = row;
|
||||
const firstId = ids[0] || '';
|
||||
const hiddenCount = ids.length - 1;
|
||||
// Filter out empty strings — the backend may return trailing empty
|
||||
// entries that would inflate hiddenCount and cause a spurious comma.
|
||||
const nonEmpty = ids.filter((id) => id.trim() !== '');
|
||||
const firstId = nonEmpty[0] || '';
|
||||
const hiddenCount = nonEmpty.length - 1;
|
||||
|
||||
return (
|
||||
<div class={s.cell}>
|
||||
@@ -69,7 +72,6 @@ export const PersistentClientsTable = (props: Props) => {
|
||||
<div class={s.idsRow}>
|
||||
<span class={cn(theme.common.textOverflow, s.idsText)}>
|
||||
{firstId}
|
||||
<Show when={hiddenCount > 0}>,</Show>
|
||||
</span>
|
||||
<Show when={hiddenCount > 0}>
|
||||
<Dropdown
|
||||
@@ -78,7 +80,7 @@ export const PersistentClientsTable = (props: Props) => {
|
||||
overlayClass={s.idsTooltipOverlay}
|
||||
menu={
|
||||
<div class={s.idsTooltip}>
|
||||
<For each={ids}>
|
||||
<For each={nonEmpty}>
|
||||
{(id) => (
|
||||
<span class={s.idsTooltipItem}>{id}</span>
|
||||
)}
|
||||
@@ -90,7 +92,7 @@ export const PersistentClientsTable = (props: Props) => {
|
||||
s.copyBtnGreen,
|
||||
s.copyBtnTopRight,
|
||||
)}
|
||||
onClick={() => handleCopy(ids.join(', '))}
|
||||
onClick={() => handleCopy(nonEmpty.join(', '))}
|
||||
title={intl.getMessage('copy')}
|
||||
>
|
||||
<Icon icon="copy" color="green" />
|
||||
@@ -218,9 +220,11 @@ export const PersistentClientsTable = (props: Props) => {
|
||||
text: intl.getMessage('tags_title'),
|
||||
className: s.headerCell,
|
||||
},
|
||||
accessor: 'tags',
|
||||
sortable: false,
|
||||
render: (value: string[]) => <TagCell tags={value} onCopy={handleCopy} />,
|
||||
accessor: (row: Client) => row.tags.join(','),
|
||||
sortable: true,
|
||||
render: (_value: string, row: Client) => (
|
||||
<TagCell tags={row.tags} onCopy={handleCopy} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'requests',
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
MAX_PASSWORD_LENGTH,
|
||||
MIN_PASSWORD_LENGTH,
|
||||
R_HOSTNAME,
|
||||
UINT32_RANGE,
|
||||
} from './constants';
|
||||
|
||||
import { ip4ToInt, isValidAbsolutePath } from './form';
|
||||
@@ -598,10 +599,7 @@ export const validateMacNotDuplicate =
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const validateMaxValue = (
|
||||
value: number,
|
||||
max: number,
|
||||
): string | undefined => {
|
||||
export const validateMaxValue = (value: number, max: number): string | undefined => {
|
||||
if (value > max) {
|
||||
return intl.getMessage('form_value_length_common_error', {
|
||||
max_length: String(max),
|
||||
@@ -610,11 +608,7 @@ export const validateMaxValue = (
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const validateBetween = (
|
||||
value: number,
|
||||
min: number,
|
||||
max: number,
|
||||
): string | undefined => {
|
||||
export const validateBetween = (value: number, min: number, max: number): string | undefined => {
|
||||
if (value < min || value > max) {
|
||||
return intl.getMessage('form_value_value_between_error', {
|
||||
min_value: String(min),
|
||||
@@ -624,10 +618,7 @@ export const validateBetween = (
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const validateMinValue = (
|
||||
value: number,
|
||||
min: number,
|
||||
): string | undefined => {
|
||||
export const validateMinValue = (value: number, min: number): string | undefined => {
|
||||
if (value < min) {
|
||||
return intl.getMessage('form_value_value_min_error', {
|
||||
min_value: String(min),
|
||||
@@ -635,3 +626,26 @@ export const validateMinValue = (
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates the client DNS cache size.
|
||||
* Callers should gate on `upstreams_cache_enabled` before invoking.
|
||||
* Returns undefined if valid, or an i18n error message string if invalid.
|
||||
*
|
||||
* @param size - The cache size in bytes
|
||||
* @param enabled - Whether per-client upstream caching is enabled
|
||||
*
|
||||
* @example validateCacheSize(0, true) // error ("must be greater than zero")
|
||||
* @example validateCacheSize(1000, true) // undefined (valid)
|
||||
* @example validateCacheSize(4294967296, true) // error (exceeds UINT32_MAX)
|
||||
* @example validateCacheSize(0, false) // undefined (no validation when disabled)
|
||||
*/
|
||||
export const validateCacheSize = (size: number, enabled: boolean): ValidationResult => {
|
||||
if (!enabled) {
|
||||
return undefined;
|
||||
}
|
||||
if (size === 0) {
|
||||
return intl.getMessage('cache_config_size_validation');
|
||||
}
|
||||
return validateMaxValue(size, UINT32_RANGE.MAX);
|
||||
};
|
||||
|
||||
@@ -3,9 +3,9 @@ import { untrack } from 'solid-js';
|
||||
import type { ClientFormState, Client } from 'panel/initialState';
|
||||
import { apiClient } from 'panel/api/Api';
|
||||
import intl from 'panel/common/intl';
|
||||
import { validateIdentifier } from 'panel/helpers/validators';
|
||||
import { validateIdentifier, validateCacheSize } from 'panel/helpers/validators';
|
||||
import { addErrorToast, addSuccessToast } from './toasts';
|
||||
import { getClients } from './dashboard';
|
||||
import { getClients, dashboardState } from './dashboard';
|
||||
|
||||
const getInitialClientFormState = (): ClientFormState => ({
|
||||
mode: 'add',
|
||||
@@ -138,6 +138,15 @@ export const buildClientConfig = (form: ClientFormState) => ({
|
||||
upstreams_cache_size: form.upstreams_cache_size,
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns all identifier strings from other persistent clients, excluding the
|
||||
* client currently being edited.
|
||||
*/
|
||||
export const computeExistingClientIds = (): string[] =>
|
||||
(dashboardState.clients || [])
|
||||
.filter((c: Client) => state.mode !== 'edit' || c.name !== state.originalName)
|
||||
.flatMap((c: Client) => c.ids);
|
||||
|
||||
export const saveClient = async (): Promise<boolean> => {
|
||||
const errors: Record<string, string | string[]> = {};
|
||||
|
||||
@@ -145,16 +154,29 @@ export const saveClient = async (): Promise<boolean> => {
|
||||
errors.name = intl.getMessage('form_error_required');
|
||||
}
|
||||
|
||||
const existingClientIds = computeExistingClientIds();
|
||||
|
||||
const idErrors = state.ids.map((id: string, index: number) => {
|
||||
if (!id.trim()) {
|
||||
return intl.getMessage('form_error_required');
|
||||
}
|
||||
return validateIdentifier(id, state.ids, index);
|
||||
return validateIdentifier(id, state.ids, index, existingClientIds);
|
||||
});
|
||||
if (idErrors.some((e: string | undefined) => e !== undefined)) {
|
||||
errors.ids = idErrors as string[];
|
||||
}
|
||||
|
||||
// Validate cache size when per-client cache is enabled (and not using global settings).
|
||||
// When use_global_settings is true, upstream settings are inherited; the cache size
|
||||
// value is still sent to the API but the backend ignores it. We skip validation to
|
||||
// avoid false positives on stale/inherited values.
|
||||
if (!state.use_global_settings && state.upstreams_cache_enabled) {
|
||||
const cacheErr = validateCacheSize(state.upstreams_cache_size, true);
|
||||
if (cacheErr) {
|
||||
errors.upstreams_cache_size = cacheErr;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setFormErrors(errors);
|
||||
return false;
|
||||
|
||||
Reference in New Issue
Block a user