code review & pivot

This commit is contained in:
Seth Van Niekerk 2026-05-01 09:10:37 -04:00
parent 0eb99381ac
commit a01814069a
No known key found for this signature in database
GPG key ID: E86ACA677312A675
15 changed files with 174 additions and 208 deletions

View file

@ -1,16 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0006_user_stream_limit'),
]
operations = [
migrations.AddField(
model_name='user',
name='xc_allowed_ips',
field=models.TextField(blank=True, default=''),
),
]

View file

@ -31,7 +31,6 @@ class User(AbstractUser):
custom_properties = models.JSONField(default=dict, blank=True, null=True)
api_key = models.CharField(max_length=200, blank=True, null=True, db_index=True)
stream_limit = models.IntegerField(default=0)
xc_allowed_ips = models.TextField(blank=True, default='')
def __str__(self):
return self.username

View file

@ -6,7 +6,8 @@ from dispatcharr.utils import network_access_allowed
class Authenticated(IsAuthenticated):
def has_permission(self, request, view):
is_authenticated = super().has_permission(request, view)
network_allowed = network_access_allowed(request, "UI")
user = request.user if hasattr(request, 'user') and request.user.is_authenticated else None
network_allowed = network_access_allowed(request, "UI", user)
return is_authenticated and network_allowed

View file

@ -1,5 +1,4 @@
import json
import ipaddress
from rest_framework import serializers
from django.contrib.auth.models import Group, Permission
@ -67,7 +66,6 @@ class UserSerializer(serializers.ModelSerializer):
"custom_properties",
"avatar_config",
"stream_limit",
"xc_allowed_ips",
"is_staff",
"is_superuser",
"last_login",
@ -76,19 +74,6 @@ class UserSerializer(serializers.ModelSerializer):
"last_name",
]
def validate_xc_allowed_ips(self, value):
if not value or not value.strip():
return value
for cidr in value.split(','):
cidr = cidr.strip()
if not cidr:
continue
try:
ipaddress.ip_network(cidr, strict=False)
except ValueError:
raise serializers.ValidationError(f"'{cidr}' is not a valid CIDR range")
return value
def validate_custom_properties(self, value):
"""Validate custom_properties structure and size."""
if value is None:

View file

@ -7,7 +7,7 @@ from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.epg.models import ProgramData
from apps.accounts.models import User
from dispatcharr.utils import network_access_allowed, user_xc_ip_allowed
from dispatcharr.utils import network_access_allowed
from django.utils import timezone as django_timezone
from django.shortcuts import get_object_or_404
from datetime import datetime, timedelta
@ -1926,7 +1926,7 @@ def xc_get_user(request):
if custom_properties["xc_password"] != password:
return None
if not user_xc_ip_allowed(request, user):
if not network_access_allowed(request, 'XC_API', user):
return None
return user

View file

@ -40,7 +40,7 @@ from .url_utils import (
from .utils import get_logger
from uuid import UUID
import gevent
from dispatcharr.utils import network_access_allowed, user_xc_ip_allowed
from dispatcharr.utils import network_access_allowed
from apps.proxy.utils import check_user_stream_limits
logger = get_logger()
@ -572,7 +572,7 @@ def stream_xc(request, username, password, channel_id):
if custom_properties["xc_password"] != password:
return Response({"error": "Invalid credentials"}, status=401)
if not user_xc_ip_allowed(request, user):
if not network_access_allowed(request, 'STREAMS', user):
return Response({"error": "Invalid credentials"}, status=401)
if user.user_level < 10:

View file

@ -19,7 +19,7 @@ from rest_framework.permissions import AllowAny
from apps.accounts.models import User
from apps.accounts.permissions import IsAdmin
from apps.proxy.utils import check_user_stream_limits
from dispatcharr.utils import network_access_allowed, user_xc_ip_allowed
from dispatcharr.utils import network_access_allowed
logger = logging.getLogger(__name__)
@ -1031,7 +1031,7 @@ def stream_xc_movie(request, username, password, stream_id, extension):
if custom_properties["xc_password"] != password:
return Response({"error": "Invalid credentials"}, status=401)
if not user_xc_ip_allowed(request, user):
if not network_access_allowed(request, 'STREAMS', user):
return Response({"error": "Invalid credentials"}, status=401)
# All authenticated users get access to VOD from all active M3U accounts
@ -1068,7 +1068,7 @@ def stream_xc_episode(request, username, password, stream_id, extension):
if custom_properties["xc_password"] != password:
return Response({"error": "Invalid credentials"}, status=401)
if not user_xc_ip_allowed(request, user):
if not network_access_allowed(request, 'STREAMS', user):
return Response({"error": "Invalid credentials"}, status=401)
# All authenticated users get access to series/episodes from all active M3U accounts

View file

@ -0,0 +1,22 @@
from django.core.management.base import BaseCommand
from apps.accounts.models import User
class Command(BaseCommand):
help = "Reset per-user network access restrictions"
def add_arguments(self, parser):
parser.add_argument('--user', type=str, help='Username to reset (omit to reset all users)')
def handle(self, *args, **options):
username = options.get('user')
qs = User.objects.filter(username=username) if username else User.objects.all()
count = 0
for user in qs:
props = user.custom_properties or {}
if 'allowed_networks' in props:
del props['allowed_networks']
user.custom_properties = props
user.save(update_fields=['custom_properties'])
count += 1
self.stdout.write(f"Reset allowed_networks for {count} user(s).")

View file

@ -38,27 +38,7 @@ def get_client_ip(request):
return ip
def user_xc_ip_allowed(request, user):
"""Check request IP against per-user XC allowed ranges. Empty = allow all (0.0.0.0/0)."""
allowed_ips = getattr(user, 'xc_allowed_ips', '') or ''
if not allowed_ips.strip():
return True
cidrs = [c.strip() for c in allowed_ips.split(',') if c.strip()]
if not cidrs:
return True
client_ip = ipaddress.ip_address(get_client_ip(request))
for cidr in cidrs:
try:
if client_ip in ipaddress.ip_network(cidr, strict=False):
return True
except ValueError:
continue
return False
def network_access_allowed(request, settings_key):
def network_access_allowed(request, settings_key, user=None):
try:
network_access = CoreSettings.objects.get(key=NETWORK_ACCESS_KEY).value
except CoreSettings.DoesNotExist:
@ -86,4 +66,19 @@ def network_access_allowed(request, settings_key):
network_allowed = True
break
return network_allowed
if not network_allowed:
return False
if user is not None:
user_networks = (getattr(user, 'custom_properties', None) or {}).get('allowed_networks', {})
raw = user_networks.get(settings_key, '')
if raw:
for cidr in (c.strip() for c in raw.split(',') if c.strip()):
try:
if client_ip in ipaddress.ip_network(cidr, strict=False):
return True
except ValueError:
continue
return False
return True

View file

@ -21,10 +21,18 @@ import { RotateCcwKey, X } from 'lucide-react';
import { Copy, Key } from 'lucide-react';
import { useForm } from '@mantine/form';
import useChannelsStore from '../../store/channels';
import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants';
import { USER_LEVELS, USER_LEVEL_LABELS, NETWORK_ACCESS_OPTIONS } from '../../constants';
import useAuthStore from '../../store/auth';
import { copyToClipboard } from '../../utils';
import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX, IPV4_REGEX, IPV6_REGEX } from '../../utils/networkUtils';
import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../../utils/networkUtils';
const isValidNetworkEntry = (entry) =>
entry.match(IPV4_CIDR_REGEX) ||
entry.match(IPV6_CIDR_REGEX) ||
(entry + '/32').match(IPV4_CIDR_REGEX) ||
(entry + '/128').match(IPV6_CIDR_REGEX);
const NETWORK_KEYS = Object.keys(NETWORK_ACCESS_OPTIONS);
const User = ({ user = null, isOpen, onClose }) => {
const profiles = useChannelsStore((s) => s.profiles);
@ -50,11 +58,11 @@ const User = ({ user = null, isOpen, onClose }) => {
stream_limit: 0,
password: '',
xc_password: '',
xc_allowed_ips: [],
channel_profiles: [],
hide_adult_content: false,
epg_days: 0,
epg_prev_days: 0,
allowed_ips: [],
},
validate: (values) => ({
@ -66,20 +74,14 @@ const User = ({ user = null, isOpen, onClose }) => {
: null,
password:
!user && !values.password && values.user_level != USER_LEVELS.STREAMER
? 'Password is requried'
? 'Password is required'
: null,
xc_password:
values.xc_password && !values.xc_password.match(/^[a-z0-9]+$/i)
? 'XC password must be alphanumeric'
: null,
xc_allowed_ips: values.xc_allowed_ips.some(
(entry) =>
!entry.match(IPV4_CIDR_REGEX) &&
!entry.match(IPV6_CIDR_REGEX) &&
!entry.match(IPV4_REGEX) &&
!entry.match(IPV6_REGEX)
)
? 'Each entry must be a valid IP address or CIDR range (e.g. 192.168.1.1 or 192.168.1.0/24)'
allowed_ips: (values.allowed_ips || []).some((t) => !isValidNetworkEntry(t))
? 'Invalid IP address or CIDR range'
: null,
}),
});
@ -102,18 +104,12 @@ const User = ({ user = null, isOpen, onClose }) => {
const customProps = user?.custom_properties || {};
// Always save xc_password, even if it's empty (to allow clearing)
customProps.xc_password = values.xc_password || '';
delete values.xc_password;
// Serialize xc_allowed_ips array to comma-separated string for the backend
values.xc_allowed_ips = (values.xc_allowed_ips || []).join(',');
// Save hide_adult_content in custom_properties
customProps.hide_adult_content = values.hide_adult_content || false;
delete values.hide_adult_content;
// Save EPG defaults in custom_properties
customProps.epg_days = values.epg_days || 0;
delete values.epg_days;
customProps.epg_prev_days = values.epg_prev_days || 0;
@ -121,13 +117,18 @@ const User = ({ user = null, isOpen, onClose }) => {
values.custom_properties = customProps;
// If 'All' is included, clear this and we assume access to all channels
// Serialize per-user network restrictions into custom_properties (same list for all types)
const joined = (values.allowed_ips || []).join(',');
delete values.allowed_ips;
const allowed_networks = {};
if (joined) NETWORK_KEYS.forEach((key) => { allowed_networks[key] = joined; });
customProps.allowed_networks = allowed_networks;
if (values.channel_profiles.includes('0')) {
values.channel_profiles = [];
}
if (!user && values.user_level == USER_LEVELS.STREAMER) {
// Generate random password - they can't log in, but user can't be created without a password
values.password = Math.random().toString(36).slice(2);
}
@ -157,6 +158,7 @@ const User = ({ user = null, isOpen, onClose }) => {
useEffect(() => {
if (user?.id) {
const customProps = user.custom_properties || {};
const networks = customProps.allowed_networks || {};
form.setValues({
username: user.username,
@ -170,12 +172,14 @@ const User = ({ user = null, isOpen, onClose }) => {
? user.channel_profiles.map((id) => `${id}`)
: ['0'],
xc_password: customProps.xc_password || '',
xc_allowed_ips: user.xc_allowed_ips
? user.xc_allowed_ips.split(',').filter(Boolean)
: [],
hide_adult_content: customProps.hide_adult_content || false,
epg_days: customProps.epg_days || 0,
epg_prev_days: customProps.epg_prev_days || 0,
allowed_ips: [...new Set(
NETWORK_KEYS.flatMap((key) =>
networks[key] ? networks[key].split(',').filter(Boolean) : []
)
)],
});
if (customProps.xc_password) {
@ -241,12 +245,10 @@ const User = ({ user = null, isOpen, onClose }) => {
}
const resp = await API.revokeApiKey(payload);
// backend returns { success: true } - clear local state
if (resp && resp.success) {
setGeneratedKey(null);
setUserAPIKey(null);
// If we're revoking the current authenticated user's key, update auth store
if (user?.id && authUser?.id === user.id) {
setUser({ ...authUser, api_key: null });
}
@ -404,12 +406,12 @@ const User = ({ user = null, isOpen, onClose }) => {
/>
{isAdmin && (
<TagsInput
label="XC Allowed IP Ranges"
description="Restrict XC access to these IP addresses or CIDR ranges. Leave empty to allow all (0.0.0.0/0)."
label="Allowed IPs"
description="Restrict all access for this user by IP. Leave empty to inherit global settings."
placeholder="e.g. 192.168.1.1 or 192.168.1.0/24"
splitChars={[',', ' ']}
{...form.getInputProps('xc_allowed_ips')}
key={form.key('xc_allowed_ips')}
{...form.getInputProps('allowed_ips')}
key={form.key('allowed_ips')}
/>
)}
{canGenerateKey && (

View file

@ -6,7 +6,7 @@ import {
checkSetting,
updateSetting,
} from '../../../utils/pages/SettingsUtils.js';
import { Alert, Button, Flex, Stack, Text, TextInput } from '@mantine/core';
import { Alert, Button, Flex, Stack, TagsInput, Text } from '@mantine/core';
import ConfirmationDialog from '../../ConfirmationDialog.jsx';
import {
getNetworkAccessFormInitialValues,
@ -14,6 +14,9 @@ import {
getNetworkAccessDefaults,
} from '../../../utils/forms/settings/NetworkAccessFormUtils.js';
const toTags = (str) => (str ? str.split(',').map((s) => s.trim()).filter(Boolean) : []);
const toStr = (tags) => (tags || []).join(',');
const NetworkAccessForm = React.memo(({ active }) => {
const settings = useSettingsStore((s) => s.settings);
@ -43,14 +46,12 @@ const NetworkAccessForm = React.memo(({ active }) => {
useEffect(() => {
const networkAccessSettings = settings['network_access']?.value || {};
// M3U/EPG endpoints default to local networks only
const m3uEpgDefaults =
'127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,::1/128,fc00::/7,fe80::/10';
const defaults = getNetworkAccessDefaults();
networkAccessForm.setValues(
Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
const defaultValue =
key === 'M3U_EPG' ? m3uEpgDefaults : '0.0.0.0/0,::/0';
acc[key] = networkAccessSettings[key] || defaultValue;
acc[key] = networkAccessSettings[key]
? toTags(networkAccessSettings[key])
: defaults[key];
return acc;
}, {})
);
@ -65,24 +66,28 @@ const NetworkAccessForm = React.memo(({ active }) => {
setNetworkAccessError(null);
setRestoredDefaults([]);
// Check for blank fields and substitute defaults before saving
const currentValues = networkAccessForm.getValues();
const defaults = getNetworkAccessDefaults();
const restoredLabels = [];
const submitValues = { ...currentValues };
const tagValues = { ...currentValues };
Object.keys(currentValues).forEach((key) => {
if (!currentValues[key] || currentValues[key].trim() === '') {
submitValues[key] = defaults[key];
if (!currentValues[key] || currentValues[key].length === 0) {
tagValues[key] = defaults[key];
restoredLabels.push(NETWORK_ACCESS_OPTIONS[key]?.label || key);
}
});
if (restoredLabels.length > 0) {
networkAccessForm.setValues(submitValues);
networkAccessForm.setValues(tagValues);
setRestoredDefaults(restoredLabels);
}
// Backend expects comma-separated strings
const submitValues = Object.fromEntries(
Object.entries(tagValues).map(([k, v]) => [k, toStr(v)])
);
pendingSaveValuesRef.current = submitValues;
const check = await checkSetting({
@ -111,8 +116,9 @@ const NetworkAccessForm = React.memo(({ active }) => {
const saveNetworkAccess = async () => {
setSaved(false);
setSaving(true);
const values =
pendingSaveValuesRef.current || networkAccessForm.getValues();
const values = pendingSaveValuesRef.current || Object.fromEntries(
Object.entries(networkAccessForm.getValues()).map(([k, v]) => [k, toStr(v)])
);
try {
await updateSetting({
...settings['network_access'],
@ -136,11 +142,7 @@ const NetworkAccessForm = React.memo(({ active }) => {
<form onSubmit={networkAccessForm.onSubmit(onNetworkAccessSubmit)}>
<Stack gap="sm">
{saved && (
<Alert
variant="light"
color="green"
title="Saved Successfully"
></Alert>
<Alert variant="light" color="green" title="Saved Successfully" />
)}
{restoredDefaults.length > 0 && (
<Alert variant="light" color="yellow" title="Defaults Restored">
@ -149,35 +151,25 @@ const NetworkAccessForm = React.memo(({ active }) => {
</Alert>
)}
{networkAccessError && (
<Alert
variant="light"
color="red"
title={networkAccessError}
></Alert>
<Alert variant="light" color="red" title={networkAccessError} />
)}
{Object.entries(NETWORK_ACCESS_OPTIONS).map(([key, config]) => (
<TextInput
<TagsInput
label={config.label}
description={config.description}
placeholder="e.g. 192.168.1.1 or 192.168.1.0/24"
splitChars={[',', ' ']}
{...networkAccessForm.getInputProps(key)}
key={networkAccessForm.key(key)}
description={config.description}
/>
))}
<Flex mih={50} gap="xs" justify="space-between" align="flex-end">
<Button
variant="subtle"
color="gray"
onClick={resetNetworkAccessToDefaults}
>
<Button variant="subtle" color="gray" onClick={resetNetworkAccessToDefaults}>
Reset to Defaults
</Button>
<Button
type="submit"
disabled={networkAccessForm.submitting}
variant="default"
>
<Button type="submit" disabled={networkAccessForm.submitting} variant="default">
Save
</Button>
</Flex>
@ -188,7 +180,7 @@ const NetworkAccessForm = React.memo(({ active }) => {
opened={networkAccessConfirmOpen}
onClose={() => setNetworkAccessConfirmOpen(false)}
onConfirm={saveNetworkAccess}
title={`Confirm Network Access Blocks`}
title="Confirm Network Access Blocks"
loading={saving}
message={
<>
@ -197,10 +189,9 @@ const NetworkAccessForm = React.memo(({ active }) => {
included in the allowed networks for the web UI. Are you sure you
want to proceed?
</Text>
<ul>
{netNetworkAccessConfirmCIDRs.map((cidr) => (
<li>{cidr}</li>
<li key={cidr}>{cidr}</li>
))}
</ul>
</>

View file

@ -4,11 +4,12 @@ import NetworkAccessForm from '../NetworkAccessForm';
// Constants mock
vi.mock('../../../../constants.js', () => ({
NETWORK_ACCESS_OPTIONS: [
{ value: 'all', label: 'All' },
{ value: 'local', label: 'Local Only' },
{ value: 'custom', label: 'Custom' },
],
NETWORK_ACCESS_OPTIONS: {
M3U_EPG: { label: 'M3U / EPG Endpoints', description: 'Limit M3U/EPG access' },
STREAMS: { label: 'Stream Endpoints', description: 'Limit stream access' },
XC_API: { label: 'XC API', description: 'Limit XC API access' },
UI: { label: 'UI', description: 'Limit UI access' },
},
}));
// Store mock
@ -82,6 +83,12 @@ vi.mock('@mantine/core', () => ({
{error && <span data-testid={`${id}-error`}>{error}</span>}
</div>
),
TagsInput: ({ label, placeholder, error, ...rest }) => (
<div>
<input aria-label={label} placeholder={placeholder} data-error={error} readOnly />
{error && <span>{error}</span>}
</div>
),
}));
//
@ -103,12 +110,10 @@ import {
// Helpers
//
const mockInitialValues = {
m3u_access: 'local',
epg_access: 'local',
recordings_access: 'all',
m3u_custom_cidrs: '',
epg_custom_cidrs: '',
recordings_custom_cidrs: '',
M3U_EPG: ['127.0.0.0/8', '192.168.0.0/16'],
STREAMS: ['0.0.0.0/0', '::/0'],
XC_API: ['0.0.0.0/0', '::/0'],
UI: ['0.0.0.0/0', '::/0'],
};
const makeFormMock = (overrides = {}) => ({
@ -134,9 +139,10 @@ const makeSettings = (overrides = {}) => ({
network_access: {
key: 'network_access',
value: {
m3u_access: 'local',
epg_access: 'local',
recordings_access: 'all',
M3U_EPG: '127.0.0.0/8,192.168.0.0/16',
STREAMS: '0.0.0.0/0,::/0',
XC_API: '0.0.0.0/0,::/0',
UI: '0.0.0.0/0,::/0',
},
},
...overrides,

View file

@ -2,46 +2,34 @@ import { NETWORK_ACCESS_OPTIONS } from '../../../constants.js';
import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../../networkUtils.js';
// Default CIDR ranges for M3U/EPG endpoints (local networks only)
const M3U_EPG_DEFAULTS =
'127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,::1/128,fc00::/7,fe80::/10';
const M3U_EPG_DEFAULTS = ['127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '::1/128', 'fc00::/7', 'fe80::/10'];
const OPEN_DEFAULTS = ['0.0.0.0/0', '::/0'];
export const getNetworkAccessFormInitialValues = () => {
return Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
const isValidEntry = (entry) =>
entry.match(IPV4_CIDR_REGEX) ||
entry.match(IPV6_CIDR_REGEX) ||
(entry + '/32').match(IPV4_CIDR_REGEX) ||
(entry + '/128').match(IPV6_CIDR_REGEX);
export const getNetworkAccessFormInitialValues = () =>
Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
// M3U/EPG endpoints default to local networks only
acc[key] = key === 'M3U_EPG' ? M3U_EPG_DEFAULTS : '0.0.0.0/0,::/0';
acc[key] = key === 'M3U_EPG' ? M3U_EPG_DEFAULTS : OPEN_DEFAULTS;
return acc;
}, {});
};
export const getNetworkAccessFormValidation = () => {
return Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
acc[key] = (value) => {
if (!value || value.trim() === '') {
return null; // Empty values will be replaced with defaults on submit
}
if (
value
.split(',')
.some(
(cidr) =>
!(cidr.match(IPV4_CIDR_REGEX) || cidr.match(IPV6_CIDR_REGEX))
)
) {
return 'Invalid CIDR range';
}
return null;
export const getNetworkAccessFormValidation = () =>
Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
acc[key] = (tags) => {
if (!tags || tags.length === 0) return null;
return tags.some((t) => !isValidEntry(t)) ? 'Invalid IP address or CIDR range' : null;
};
return acc;
}, {});
};
export const getNetworkAccessDefaults = () => {
return {
M3U_EPG: M3U_EPG_DEFAULTS,
STREAMS: '0.0.0.0/0,::/0',
XC_API: '0.0.0.0/0,::/0',
UI: '0.0.0.0/0,::/0',
};
};
export const getNetworkAccessDefaults = () => ({
M3U_EPG: M3U_EPG_DEFAULTS,
STREAMS: OPEN_DEFAULTS,
XC_API: OPEN_DEFAULTS,
UI: OPEN_DEFAULTS,
});

View file

@ -27,9 +27,9 @@ describe('NetworkAccessFormUtils', () => {
const result = NetworkAccessFormUtils.getNetworkAccessFormInitialValues();
expect(result).toEqual({
'network-access-admin': '0.0.0.0/0,::/0',
'network-access-api': '0.0.0.0/0,::/0',
'network-access-streaming': '0.0.0.0/0,::/0',
'network-access-admin': ['0.0.0.0/0', '::/0'],
'network-access-api': ['0.0.0.0/0', '::/0'],
'network-access-streaming': ['0.0.0.0/0', '::/0'],
});
});
@ -80,9 +80,9 @@ describe('NetworkAccessFormUtils', () => {
NetworkAccessFormUtils.getNetworkAccessFormValidation();
const validator = validation['network-access-admin'];
expect(validator('192.168.1.0/24')).toBeNull();
expect(validator('10.0.0.0/8')).toBeNull();
expect(validator('0.0.0.0/0')).toBeNull();
expect(validator(['192.168.1.0/24'])).toBeNull();
expect(validator(['10.0.0.0/8'])).toBeNull();
expect(validator(['0.0.0.0/0'])).toBeNull();
});
it('should validate valid IPv6 CIDR ranges', () => {
@ -90,18 +90,18 @@ describe('NetworkAccessFormUtils', () => {
NetworkAccessFormUtils.getNetworkAccessFormValidation();
const validator = validation['network-access-admin'];
expect(validator('2001:db8::/32')).toBeNull();
expect(validator('::/0')).toBeNull();
expect(validator(['2001:db8::/32'])).toBeNull();
expect(validator(['::/0'])).toBeNull();
});
it('should validate multiple CIDR ranges separated by commas', () => {
it('should validate multiple CIDR entries', () => {
const validation =
NetworkAccessFormUtils.getNetworkAccessFormValidation();
const validator = validation['network-access-admin'];
expect(validator('192.168.1.0/24,10.0.0.0/8')).toBeNull();
expect(validator('0.0.0.0/0,::/0')).toBeNull();
expect(validator('192.168.1.0/24,2001:db8::/32')).toBeNull();
expect(validator(['192.168.1.0/24', '10.0.0.0/8'])).toBeNull();
expect(validator(['0.0.0.0/0', '::/0'])).toBeNull();
expect(validator(['192.168.1.0/24', '2001:db8::/32'])).toBeNull();
});
it('should return error for invalid IPv4 CIDR ranges', () => {
@ -109,30 +109,31 @@ describe('NetworkAccessFormUtils', () => {
NetworkAccessFormUtils.getNetworkAccessFormValidation();
const validator = validation['network-access-admin'];
expect(validator('192.168.1.256.1/24')).toBe('Invalid CIDR range');
expect(validator('invalid')).toBe('Invalid CIDR range');
expect(validator('192.168.1.0/256')).toBe('Invalid CIDR range');
expect(validator(['192.168.1.256.1/24'])).toBe('Invalid IP address or CIDR range');
expect(validator(['invalid'])).toBe('Invalid IP address or CIDR range');
expect(validator(['192.168.1.0/256'])).toBe('Invalid IP address or CIDR range');
});
it('should return error when any CIDR in comma-separated list is invalid', () => {
it('should return error when any entry in the list is invalid', () => {
const validation =
NetworkAccessFormUtils.getNetworkAccessFormValidation();
const validator = validation['network-access-admin'];
expect(validator('192.168.1.0/24,invalid')).toBe('Invalid CIDR range');
expect(validator('invalid,192.168.1.0/24')).toBe('Invalid CIDR range');
expect(validator('192.168.1.0/24,10.0.0.0/8,invalid')).toBe(
'Invalid CIDR range'
expect(validator(['192.168.1.0/24', 'invalid'])).toBe('Invalid IP address or CIDR range');
expect(validator(['invalid', '192.168.1.0/24'])).toBe('Invalid IP address or CIDR range');
expect(validator(['192.168.1.0/24', '10.0.0.0/8', 'invalid'])).toBe(
'Invalid IP address or CIDR range'
);
});
it('should handle empty strings', () => {
it('should handle empty arrays', () => {
const validation =
NetworkAccessFormUtils.getNetworkAccessFormValidation();
const validator = validation['network-access-admin'];
// Empty values are allowed — defaults are substituted on submit
expect(validator('')).toBe(null);
expect(validator([])).toBe(null);
expect(validator(null)).toBe(null);
});
it('should return empty object when NETWORK_ACCESS_OPTIONS is empty', () => {

View file

@ -1,11 +1,3 @@
// Plain IPv4 address regex
export const IPV4_REGEX =
/^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])$/;
// Plain IPv6 address regex
export const IPV6_REGEX =
/^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))$/;
// IPv4 CIDR regex - validates IP address and prefix length (0-32)
export const IPV4_CIDR_REGEX =
/^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\/(3[0-2]|[12]?[0-9])$/;