From c89de07880d9dc161fff5d35faca629b7a738f4a Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 9 May 2026 08:30:05 -0600 Subject: [PATCH 1/9] fix(Field): increase maximum size of arrays for many fields #879 Initially there was a concern if too many objects had large 'many' values, as it did not play nicely with the level of recursion required for HATEOAS and other object relationship systems. This is not as big of a concern anymore after the ModelCache was created so increasing this significantly is warranted. --- .../files/usr/local/pkg/RESTAPI/Core/Field.inc | 7 ++++++- .../files/usr/local/pkg/RESTAPI/Fields/Base64Field.inc | 2 +- .../files/usr/local/pkg/RESTAPI/Fields/DateTimeField.inc | 2 +- .../usr/local/pkg/RESTAPI/Fields/FilterAddressField.inc | 2 +- .../files/usr/local/pkg/RESTAPI/Fields/FloatField.inc | 2 +- .../usr/local/pkg/RESTAPI/Fields/ForeignModelField.inc | 2 +- .../files/usr/local/pkg/RESTAPI/Fields/IntegerField.inc | 2 +- .../files/usr/local/pkg/RESTAPI/Fields/InterfaceField.inc | 2 +- .../files/usr/local/pkg/RESTAPI/Fields/ObjectField.inc | 2 +- .../files/usr/local/pkg/RESTAPI/Fields/PortField.inc | 2 +- .../usr/local/pkg/RESTAPI/Fields/SpecialNetworkField.inc | 2 +- .../files/usr/local/pkg/RESTAPI/Fields/StringField.inc | 2 +- .../files/usr/local/pkg/RESTAPI/Fields/UnixTimeField.inc | 2 +- 13 files changed, 18 insertions(+), 13 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Field.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Field.inc index 2e0520535..4914f2791 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Field.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Field.inc @@ -26,6 +26,11 @@ class Field { */ const SENSITIVE_MASK = '********'; + /** + * @const MANY_MAXIMUM is the maximum number of items a 'many' field can hold it's array. + */ + const MANY_MAXIMUM = 65535; + /** * Represents the current value for this Field. * @@ -144,7 +149,7 @@ class Field { public bool $representation_only = false, public bool $many = false, public int $many_minimum = 0, - public int $many_maximum = 128, + public int $many_maximum = Field::MANY_MAXIMUM, public string|null $delimiter = ',', public string $verbose_name = '', public string $verbose_name_plural = '', diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/Base64Field.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/Base64Field.inc index 9805a488a..b36fa0940 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/Base64Field.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/Base64Field.inc @@ -85,7 +85,7 @@ class Base64Field extends Field { bool $representation_only = false, bool $many = false, int $many_minimum = 0, - int $many_maximum = 128, + int $many_maximum = Field::MANY_MAXIMUM, string|null $delimiter = ',', string $verbose_name = '', string $verbose_name_plural = '', diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/DateTimeField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/DateTimeField.inc index 34b85f9bc..b0a992db0 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/DateTimeField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/DateTimeField.inc @@ -93,7 +93,7 @@ class DateTimeField extends Field { bool $representation_only = false, bool $many = false, int $many_minimum = 0, - int $many_maximum = 128, + int $many_maximum = Field::MANY_MAXIMUM, string|null $delimiter = ',', string $verbose_name = '', string $verbose_name_plural = '', diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/FilterAddressField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/FilterAddressField.inc index c74aa3752..41112e8a9 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/FilterAddressField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/FilterAddressField.inc @@ -111,7 +111,7 @@ class FilterAddressField extends InterfaceField { bool $representation_only = false, bool $many = false, int $many_minimum = 0, - int $many_maximum = 128, + int $many_maximum = Field::MANY_MAXIMUM, string|null $delimiter = ',', string $verbose_name = '', string $verbose_name_plural = '', diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/FloatField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/FloatField.inc index 25665d8e5..62568cf28 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/FloatField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/FloatField.inc @@ -80,7 +80,7 @@ class FloatField extends RESTAPI\Core\Field { bool $representation_only = false, bool $many = false, int $many_minimum = 0, - int $many_maximum = 128, + int $many_maximum = Field::MANY_MAXIMUM, public int $minimum = 0, public int $maximum = PHP_INT_MAX, string|null $delimiter = ',', diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/ForeignModelField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/ForeignModelField.inc index a740b1896..9a2920e88 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/ForeignModelField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/ForeignModelField.inc @@ -118,7 +118,7 @@ class ForeignModelField extends Field { bool $representation_only = false, bool $many = false, int $many_minimum = 0, - int $many_maximum = 128, + int $many_maximum = Field::MANY_MAXIMUM, string|null $delimiter = ',', string $verbose_name = '', string $verbose_name_plural = '', diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/IntegerField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/IntegerField.inc index d77c79a1f..538887dd0 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/IntegerField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/IntegerField.inc @@ -81,7 +81,7 @@ class IntegerField extends Field { bool $representation_only = false, bool $many = false, int $many_minimum = 0, - int $many_maximum = 128, + int $many_maximum = Field::MANY_MAXIMUM, public int $minimum = 0, public int $maximum = PHP_INT_MAX, string|null $delimiter = ',', diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/InterfaceField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/InterfaceField.inc index c37421f05..9d4223a34 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/InterfaceField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/InterfaceField.inc @@ -97,7 +97,7 @@ class InterfaceField extends StringField { bool $editable = true, bool $many = false, int $many_minimum = 0, - int $many_maximum = 128, + int $many_maximum = Field::MANY_MAXIMUM, string|null $delimiter = ',', string $verbose_name = '', string $verbose_name_plural = '', diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/ObjectField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/ObjectField.inc index 8b323da03..a88a52f26 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/ObjectField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/ObjectField.inc @@ -90,7 +90,7 @@ class ObjectField extends Field { bool $representation_only = false, bool $many = false, int $many_minimum = 0, - int $many_maximum = 128, + int $many_maximum = Field::MANY_MAXIMUM, public int $minimum_length = 0, public int $maximum_length = 1024, string|null $delimiter = ',', diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/PortField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/PortField.inc index 097ebaafc..afed1b7a0 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/PortField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/PortField.inc @@ -94,7 +94,7 @@ class PortField extends Field { bool $representation_only = false, bool $many = false, int $many_minimum = 0, - int $many_maximum = 128, + int $many_maximum = Field::MANY_MAXIMUM, ?string $delimiter = ',', string $verbose_name = '', string $verbose_name_plural = '', diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/SpecialNetworkField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/SpecialNetworkField.inc index 07c3af67e..8665e8d19 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/SpecialNetworkField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/SpecialNetworkField.inc @@ -103,7 +103,7 @@ class SpecialNetworkField extends InterfaceField { bool $representation_only = false, bool $many = false, int $many_minimum = 0, - int $many_maximum = 128, + int $many_maximum = Field::MANY_MAXIMUM, string|null $delimiter = ',', string $verbose_name = '', string $verbose_name_plural = '', diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/StringField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/StringField.inc index c239f0345..45565b28d 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/StringField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/StringField.inc @@ -85,7 +85,7 @@ class StringField extends Field { bool $representation_only = false, bool $many = false, int $many_minimum = 0, - int $many_maximum = 128, + int $many_maximum = Field::MANY_MAXIMUM, public int $minimum_length = 0, public int $maximum_length = 1024, string|null $delimiter = ',', diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/UnixTimeField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/UnixTimeField.inc index 47aac9c52..f5da69ac1 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/UnixTimeField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/UnixTimeField.inc @@ -85,7 +85,7 @@ class UnixTimeField extends IntegerField { bool $representation_only = false, bool $many = false, int $many_minimum = 0, - int $many_maximum = 128, + int $many_maximum = Field::MANY_MAXIMUM, int $minimum = 0, int $maximum = PHP_INT_MAX, public bool $auto_add_now = true, From f17217569044640c8be9508d9643fb4d16dd925b Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 9 May 2026 09:15:33 -0600 Subject: [PATCH 2/9] fix(IPsecPhase2Encryption): allow 'auto' keylen to be represented as 0 #880 --- .../local/pkg/RESTAPI/Fields/KeyLenField.inc | 204 ++++++++++++++++++ .../RESTAPI/Models/IPsecPhase2Encryption.inc | 33 +-- .../Tests/APIFieldsKeyLenFieldTestCase.inc | 97 +++++++++ ...APIModelsIPsecPhase2EncryptionTestCase.inc | 45 ++-- 4 files changed, 335 insertions(+), 44 deletions(-) create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/KeyLenField.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsKeyLenFieldTestCase.inc diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/KeyLenField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/KeyLenField.inc new file mode 100644 index 000000000..67ff48ea5 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/KeyLenField.inc @@ -0,0 +1,204 @@ + "type1"] to this parameter. + * @param array $validators An array of Validator objects to run against this field. + * @param string $help_text Set a description for this field. This description will be used in API documentation. + */ + public function __construct( + bool $required = false, + bool $unique = false, + mixed $default = null, + string $default_callable = '', + array $choices = [], + string $choices_callable = '', + bool $allow_null = false, + bool $editable = true, + bool $read_only = false, + bool $write_only = false, + bool $representation_only = false, + bool $many = false, + int $many_minimum = 0, + int $many_maximum = Field::MANY_MAXIMUM, + public int $minimum = 0, + public int $maximum = PHP_INT_MAX, + string|null $delimiter = ',', + string $verbose_name = '', + string $verbose_name_plural = '', + string $internal_name = '', + string $internal_namespace = '', + array $referenced_by = [], + array $conditions = [], + array $validators = [], + string $help_text = '', + ) { + parent::__construct( + type: 'integer', + required: $required, + unique: $unique, + default: $default, + default_callable: $default_callable, + choices: $choices, + choices_callable: $choices_callable, + allow_null: $allow_null, + editable: $editable, + read_only: $read_only, + write_only: $write_only, + representation_only: $representation_only, + many: $many, + many_minimum: $many_minimum, + many_maximum: $many_maximum, + delimiter: $delimiter, + verbose_name: $verbose_name, + verbose_name_plural: $verbose_name_plural, + internal_name: $internal_name, + internal_namespace: $internal_namespace, + referenced_by: $referenced_by, + conditions: $conditions, + validators: $validators + [ + new RESTAPI\Validators\NumericRangeValidator(minimum: $minimum, maximum: $maximum), + ], + help_text: $help_text, + ); + } + + /** + * Converts the field value from its representation value into it's internal value. This namely handles converting + * 0 to 'auto'. + * @param mixed $representation_value The representation value to convert to its internal value + */ + protected function _to_internal(mixed $representation_value): array|string|null { + if ($representation_value === 0) { + return parent::_to_internal('auto'); + } + return parent::_to_internal($representation_value); + } + + /** + * Converts the field value to its representation form from its internal pfSense configuration value. + * @param string $internal_value The internal value from the pfSense configuration. + * @return int The field value in its representation form. + */ + protected function _from_internal(mixed $internal_value): mixed { + # Return the value as an integer if it's numeric + if (is_numeric($internal_value)) { + return intval($internal_value); + } + + # If the value is 'auto', return 0 (0 is the representation we use for auto) + if ($internal_value === 'auto') { + return 0; + } + + # If the value is an empty string, assume it's null + if ($internal_value === '') { + return null; + } + + # Otherwise, the internal value cannot be represented by this Field. Throw an error. + throw new RESTAPI\Responses\ServerError( + message: "Cannot parse KeyLenField '$this->name' from internal because its internal value is not a " . + "numeric value or 'auto'. Consider changing this field to a StringField.", + response_id: 'KEYLEN_FIELD_WITH_NON_INTEGER_INTERNAL_VALUE', + ); + } + + /** + * Converts this Field object to a PHP array representation of an OpenAPI schema property configuration. This is + * used when auto-generating API documentation. This method can be extended to add additional options to the OpenAPI + * schema property. + * @link https://swagger.io/docs/specification/data-models/ + * @return array A PHP array containing this field as a OpenAPI schema property configuration. + */ + public function to_openapi_property(): array { + # Run the parent to_openapi_property() to obtain the base property object, then make changes as needed. + $openapi_property = parent::to_openapi_property(); + + # Add the minimum and maximum to the OpenAPI property. + if ($this->many) { + $openapi_property['items']['minimum'] = $this->minimum; + $openapi_property['items']['maximum'] = $this->maximum; + } else { + $openapi_property['minimum'] = $this->minimum; + $openapi_property['maximum'] = $this->maximum; + } + + return $openapi_property; + } + + /** + * Converts this Field object into a pfSense webConfigurator form input. This method can be overridden by a child + * class to add custom input field creation. + * @param string $type The HTML input tag type. Not all Fields support input types. + * @param array $attributes An array of additional HTML input tag attributes. Not all Fields support input attributes. + * @return object The pfSense webConfigurator form input object. + * @link https://github.com/pfsense/pfsense/tree/master/src/usr/local/www/classes/Form + */ + public function to_form_input(string $type = 'number', array $attributes = []): object { + $attributes += ['min' => $this->minimum, 'max' => $this->maximum]; + return parent::to_form_input(type: $type, attributes: $attributes); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/IPsecPhase2Encryption.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/IPsecPhase2Encryption.inc index 92dcb61a1..be159645e 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/IPsecPhase2Encryption.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/IPsecPhase2Encryption.inc @@ -5,6 +5,7 @@ namespace RESTAPI\Models; use RESTAPI\Core\Model; use RESTAPI\Dispatchers\IPsecApplyDispatcher; use RESTAPI\Fields\IntegerField; +use RESTAPI\Fields\KeyLenField; use RESTAPI\Fields\StringField; use RESTAPI\Responses\ValidationError; @@ -13,7 +14,7 @@ use RESTAPI\Responses\ValidationError; */ class IPsecPhase2Encryption extends Model { public StringField $name; - public IntegerField $keylen; + public KeyLenField $keylen; public function __construct(mixed $id = null, mixed $parent_id = null, array $data = [], mixed ...$options) { # Obtain global p2 algorithm variables @@ -34,38 +35,17 @@ class IPsecPhase2Encryption extends Model { internal_name: 'name', help_text: 'The name of the encryption algorithm to use for this P2 encryption item.', ); - $this->keylen = new IntegerField( + $this->keylen = new KeyLenField( required: true, + choices_callable: 'get_supported_keylens', internal_name: 'keylen', conditions: ['name' => $this->get_keylen_enabled_algos()], - help_text: 'The key length for the encryption algorithm.', + help_text: 'The key length for the encryption algorithm. Use 0 to select key length automatically.', ); parent::__construct($id, $parent_id, $data, ...$options); } - /** - * Adds extra validation to the `keylen` field. - * @param int $keylen The incoming value to be validated. - * @returns int The validated value to be assigned. - * @throws ValidationError When the $keylen is not supported by the - * `name` field's assigned value. - */ - public function validate_keylen(int $keylen): int { - # Variables - $supported_keylens = $this->get_supported_keylens(name: $this->name->value); - - # Throw a validation error if this $keylen is not supported for the assigned algo - if (!in_array($keylen, $supported_keylens)) { - throw new ValidationError( - message: "Field `keylen` value `$keylen` is not valid for the `{$this->name->value}` algorithm.", - response_id: 'IPSEC_PHASE_2_ENCRYPTION_ALGORITHM_KEYLEN_INVALID_CHOICE', - ); - } - - return $keylen; - } - /** * Obtains all supported key lengths for an encryption algorithm with a provided algorithm name. * @param string $name The encryption algorithm name to obtain key lengths for. @@ -100,6 +80,9 @@ class IPsecPhase2Encryption extends Model { } } + # Accept '0', as this is the representation keyword for 'auto' + $key_lens[] = 0; + return $key_lens; } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsKeyLenFieldTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsKeyLenFieldTestCase.inc new file mode 100644 index 000000000..f46e5a590 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsKeyLenFieldTestCase.inc @@ -0,0 +1,97 @@ +assert_equals(get_class($field->validators[0]), 'RESTAPI\Validators\NumericRangeValidator'); + } + + /** + * Checks that `_from_internal()` converts a numeric string to an integer. + */ + public function test_from_internal_numeric_string_returns_integer(): void { + $field = new KeyLenField(); + $field->from_internal('256'); + $this->assert_equals($field->value, 256); + $this->assert_is_true(is_int($field->value)); + } + + /** + * Checks that `_from_internal()` converts the 'auto' keyword to 0. + */ + public function test_from_internal_auto_returns_zero(): void { + $field = new KeyLenField(); + $field->from_internal('auto'); + $this->assert_equals($field->value, 0); + } + + /** + * Checks that `_from_internal()` converts an empty string to null. + */ + public function test_from_internal_empty_string_returns_null(): void { + $field = new KeyLenField(allow_null: true); + $field->from_internal(''); + $this->assert_is_null($field->value); + } + + /** + * Checks that `_from_internal()` throws a ServerError for non-numeric, non-'auto' values. + */ + public function test_from_internal_invalid_value_throws_server_error(): void { + $this->assert_throws_response( + response_id: 'KEYLEN_FIELD_WITH_NON_INTEGER_INTERNAL_VALUE', + code: 500, + callable: function () { + $field = new KeyLenField(); + $field->from_internal('not_valid'); + }, + ); + } + + /** + * Checks that `keylen` can be assigned the value 0 and that `to_internal()` converts it to 'auto'. + */ + public function test_to_internal_zero_returns_auto(): void { + $field = new KeyLenField(default: 0); + $field->value = 0; + $this->assert_equals($field->value, 0); + $this->assert_equals($field->to_internal(), 'auto'); + } + + /** + * Checks that `to_internal()` passes non-zero integers through normally. + */ + public function test_to_internal_non_zero_passes_through(): void { + $field = new KeyLenField(default: 128); + $field->value = 128; + $this->assert_equals($field->to_internal(), 128); + } + + /** + * Checks that `to_openapi_property()` includes `minimum` and `maximum` for non-many fields. + */ + public function test_to_openapi_property_includes_min_max(): void { + $field = new KeyLenField(minimum: 64, maximum: 512); + $property = $field->to_openapi_property(); + $this->assert_equals($property['minimum'], 64); + $this->assert_equals($property['maximum'], 512); + } + + /** + * Checks that `to_openapi_property()` includes `minimum` and `maximum` under `items` for many fields. + */ + public function test_to_openapi_property_includes_min_max_for_many(): void { + $field = new KeyLenField(many: true, minimum: 64, maximum: 512); + $property = $field->to_openapi_property(); + $this->assert_equals($property['items']['minimum'], 64); + $this->assert_equals($property['items']['maximum'], 512); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsIPsecPhase2EncryptionTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsIPsecPhase2EncryptionTestCase.inc index 56536a12b..1a428abd8 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsIPsecPhase2EncryptionTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsIPsecPhase2EncryptionTestCase.inc @@ -3,34 +3,41 @@ namespace RESTAPI\Tests; use RESTAPI\Core\TestCase; +use RESTAPI\Fields\KeyLenField; use RESTAPI\Models\IPsecPhase2Encryption; class APIModelsIPsecPhase2EncryptionTestCase extends TestCase { # NOTE: This model is partially tested by /RESTAPI/Tests/APIModelsIPsecPhase2TestCase /** - * Checks that the `keylen` must be valid for the given encryption algorithm `name`. + * Checks that the `keylen` field is a KeyLenField instance. + */ + public function test_keylen_field_type(): void { + $p2 = new IPsecPhase2Encryption(); + $this->assert_is_true($p2->keylen instanceof KeyLenField); + } + + /** + * Checks that the `keylen` field uses `get_supported_keylens` as its choices callable. + */ + public function test_keylen_field_uses_choices_callable(): void { + $p2 = new IPsecPhase2Encryption(); + $this->assert_equals($p2->keylen->choices_callable, 'get_supported_keylens'); + } + + /** + * Checks that the `keylen` field's choices_callable correctly restricts values + * to those supported by the selected encryption algorithm. */ public function test_keylen_choices(): void { - # Ensure an error is thrown if the requested key length is not supported for the given encryption algorithm - $this->assert_throws_response( - response_id: 'IPSEC_PHASE_2_ENCRYPTION_ALGORITHM_KEYLEN_INVALID_CHOICE', - code: 400, - callable: function () { - $p2 = new IPsecPhase2Encryption(); - $p2->name->value = 'aes'; - $p2->validate_keylen(257); - }, - ); + # Ensure that `get_supported_keylens` returns only valid key lengths for 'aes' + $supported = IPsecPhase2Encryption::get_supported_keylens('aes'); + $this->assert_is_true(in_array(128, $supported, true)); + $this->assert_is_true(in_array(256, $supported, true)); + $this->assert_is_false(in_array(257, $supported, true)); - # Ensure no error is thrown when the key length is supported for the given encryption algorithm - $this->assert_does_not_throw( - callable: function () { - $p2 = new IPsecPhase2Encryption(); - $p2->name->value = 'aes'; - $p2->validate_keylen(256); - }, - ); + # Ensure 0 (auto) is always included in the supported key lengths + $this->assert_is_true(in_array(0, $supported, true)); } /** From e51496dd48f92ffa0158e69c2b3a279524b3c026 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 9 May 2026 18:43:39 -0600 Subject: [PATCH 3/9] test: fix null assertion in KeyLenField test --- .../local/pkg/RESTAPI/Tests/APIFieldsKeyLenFieldTestCase.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsKeyLenFieldTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsKeyLenFieldTestCase.inc index f46e5a590..14321d536 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsKeyLenFieldTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsKeyLenFieldTestCase.inc @@ -39,7 +39,7 @@ class APIFieldsKeyLenFieldTestCase extends TestCase { public function test_from_internal_empty_string_returns_null(): void { $field = new KeyLenField(allow_null: true); $field->from_internal(''); - $this->assert_is_null($field->value); + $this->assert_equals($field->value, null); } /** From 46092f540dc9fa72dcab7947105963bc28a6df34 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 9 May 2026 18:44:08 -0600 Subject: [PATCH 4/9] chore: cleanup unused import in IPsecPhase2Encryption --- .../files/usr/local/pkg/RESTAPI/Models/IPsecPhase2Encryption.inc | 1 - 1 file changed, 1 deletion(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/IPsecPhase2Encryption.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/IPsecPhase2Encryption.inc index be159645e..dca621c31 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/IPsecPhase2Encryption.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/IPsecPhase2Encryption.inc @@ -4,7 +4,6 @@ namespace RESTAPI\Models; use RESTAPI\Core\Model; use RESTAPI\Dispatchers\IPsecApplyDispatcher; -use RESTAPI\Fields\IntegerField; use RESTAPI\Fields\KeyLenField; use RESTAPI\Fields\StringField; use RESTAPI\Responses\ValidationError; From cd562b0b53333a7d7bcd8a6e9d7b9c5d09a68030 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 9 May 2026 18:47:28 -0600 Subject: [PATCH 5/9] chore: add missing Field class imports --- .../files/usr/local/pkg/RESTAPI/Fields/FilterAddressField.inc | 1 + .../files/usr/local/pkg/RESTAPI/Fields/FloatField.inc | 4 +++- .../files/usr/local/pkg/RESTAPI/Fields/InterfaceField.inc | 1 + .../usr/local/pkg/RESTAPI/Fields/SpecialNetworkField.inc | 1 + .../files/usr/local/pkg/RESTAPI/Fields/UIDField.inc | 3 ++- .../files/usr/local/pkg/RESTAPI/Fields/UnixTimeField.inc | 1 + 6 files changed, 9 insertions(+), 2 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/FilterAddressField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/FilterAddressField.inc index 41112e8a9..ecaa3ccc5 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/FilterAddressField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/FilterAddressField.inc @@ -5,6 +5,7 @@ namespace RESTAPI\Fields; require_once 'RESTAPI/autoloader.inc'; require_once 'RESTAPI/Fields/InterfaceField.inc'; +use RESTAPI\Core\Field; use RESTAPI\Models\FirewallAlias; use RESTAPI\Responses\ServerError; use RESTAPI\Responses\ValidationError; diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/FloatField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/FloatField.inc index 62568cf28..c188e978b 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/FloatField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/FloatField.inc @@ -5,11 +5,13 @@ namespace RESTAPI\Fields; require_once 'RESTAPI/autoloader.inc'; use RESTAPI; +use RESTAPI\Core\Field; /** * Defines a Field object for validating and storing a floating point number. */ -class FloatField extends RESTAPI\Core\Field { +class FloatField extends Field +{ /** * Defines the FloatField object and sets its options. * @param bool $required If `true`, this field is required to have a value at all times. diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/InterfaceField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/InterfaceField.inc index 9d4223a34..27d941abc 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/InterfaceField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/InterfaceField.inc @@ -6,6 +6,7 @@ require_once 'RESTAPI/Fields/StringField.inc'; require_once 'RESTAPI/autoloader.inc'; use RESTAPI; +use RESTAPI\Core\Field; use RESTAPI\Core\Model; use RESTAPI\Core\ModelSet; use RESTAPI\Models\NetworkInterface; diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/SpecialNetworkField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/SpecialNetworkField.inc index 8665e8d19..f3f76b84a 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/SpecialNetworkField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/SpecialNetworkField.inc @@ -5,6 +5,7 @@ namespace RESTAPI\Fields; require_once 'RESTAPI/autoloader.inc'; require_once 'RESTAPI/Fields/InterfaceField.inc'; +use RESTAPI\Core\Field; use RESTAPI\Models\FirewallAlias; use RESTAPI\Models\NetworkInterface; use RESTAPI\Responses\ServerError; diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/UIDField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/UIDField.inc index fa9da1481..f04efc462 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/UIDField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/UIDField.inc @@ -5,11 +5,12 @@ namespace RESTAPI\Fields; require_once 'RESTAPI/autoloader.inc'; use RESTAPI; +use RESTAPI\Core\Field; /** * Defines a Field that contains a unique ID. This field will automatically populate a unique ID that is immutable. */ -class UIDField extends RESTAPI\Core\Field { +class UIDField extends Field { /** * Defines the UIDField object and sets its options. * @param string $prefix A specific string to prefix to the generated UID. diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/UnixTimeField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/UnixTimeField.inc index f5da69ac1..1b3a23da5 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/UnixTimeField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/UnixTimeField.inc @@ -5,6 +5,7 @@ namespace RESTAPI\Fields; require_once 'RESTAPI/autoloader.inc'; use RESTAPI; +use RESTAPI\Core\Field; use RESTAPI\Responses\ServerError; /** From c82d733eb6090a60e902725e1c010841388db589 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 9 May 2026 18:47:54 -0600 Subject: [PATCH 6/9] style: run prettier on changed files --- .../files/usr/local/pkg/RESTAPI/Fields/FloatField.inc | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/FloatField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/FloatField.inc index c188e978b..234323be0 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/FloatField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/FloatField.inc @@ -10,8 +10,7 @@ use RESTAPI\Core\Field; /** * Defines a Field object for validating and storing a floating point number. */ -class FloatField extends Field -{ +class FloatField extends Field { /** * Defines the FloatField object and sets its options. * @param bool $required If `true`, this field is required to have a value at all times. From 3c9decc08fefcb434cc1cae0dcc55183eaa8424b Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sun, 10 May 2026 09:19:55 -0600 Subject: [PATCH 7/9] chore(IPsecPhase2Encryption): source algo name from name field, not direct arg --- .../local/pkg/RESTAPI/Models/IPsecPhase2Encryption.inc | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/IPsecPhase2Encryption.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/IPsecPhase2Encryption.inc index dca621c31..bd0777de1 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/IPsecPhase2Encryption.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/IPsecPhase2Encryption.inc @@ -50,19 +50,19 @@ class IPsecPhase2Encryption extends Model { * @param string $name The encryption algorithm name to obtain key lengths for. * @returns array An array of supported key lengths for this encryption algorithm. */ - public static function get_supported_keylens(string $name): array { + public function get_supported_keylens(): array { global $p2_ealgos; # Throw an error if an encryption algorithm does not exist with this name. - if (!array_key_exists($name, $p2_ealgos)) { + if (!array_key_exists($this->name->value, $p2_ealgos)) { throw new ValidationError( - message: "Could not obtain supported key lengths for unknown algorithm `$name`.", + message: "Could not obtain supported key lengths for unknown algorithm `{$this->name->value}`.", response_id: 'IPSEC_PHASE_2_ENCRYPTION_COULD_NOT_GET_KEYLENS_FOR_UNKNOWN_ALGO', ); } # Obtain the attributes for the encryption algorithm with this $name - $p2_ealgo = $p2_ealgos[$name]; + $p2_ealgo = $p2_ealgos[$this->name->value]; $key_lens = []; # Determine available key lengths for this algo when selections are available From 187cc93425d91f2acf1369e5b1737f1c65da9abf Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sun, 10 May 2026 09:30:53 -0600 Subject: [PATCH 8/9] test(KeyLenField): to internal should produce a string, not int --- .../local/pkg/RESTAPI/Tests/APIFieldsKeyLenFieldTestCase.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsKeyLenFieldTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsKeyLenFieldTestCase.inc index 14321d536..b4c9fa920 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsKeyLenFieldTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsKeyLenFieldTestCase.inc @@ -72,7 +72,7 @@ class APIFieldsKeyLenFieldTestCase extends TestCase { public function test_to_internal_non_zero_passes_through(): void { $field = new KeyLenField(default: 128); $field->value = 128; - $this->assert_equals($field->to_internal(), 128); + $this->assert_equals($field->to_internal(), '128'); } /** From 19f32921dd3487cd34f0e3a963a21f6214c6e039 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sun, 10 May 2026 09:31:24 -0600 Subject: [PATCH 9/9] test(IPsecPhase2Encryption): replace references to old get_supported_keylens --- ...APIModelsIPsecPhase2EncryptionTestCase.inc | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsIPsecPhase2EncryptionTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsIPsecPhase2EncryptionTestCase.inc index 1a428abd8..75114714d 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsIPsecPhase2EncryptionTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsIPsecPhase2EncryptionTestCase.inc @@ -9,14 +9,6 @@ use RESTAPI\Models\IPsecPhase2Encryption; class APIModelsIPsecPhase2EncryptionTestCase extends TestCase { # NOTE: This model is partially tested by /RESTAPI/Tests/APIModelsIPsecPhase2TestCase - /** - * Checks that the `keylen` field is a KeyLenField instance. - */ - public function test_keylen_field_type(): void { - $p2 = new IPsecPhase2Encryption(); - $this->assert_is_true($p2->keylen instanceof KeyLenField); - } - /** * Checks that the `keylen` field uses `get_supported_keylens` as its choices callable. */ @@ -31,7 +23,9 @@ class APIModelsIPsecPhase2EncryptionTestCase extends TestCase { */ public function test_keylen_choices(): void { # Ensure that `get_supported_keylens` returns only valid key lengths for 'aes' - $supported = IPsecPhase2Encryption::get_supported_keylens('aes'); + $p2e = new IPsecPhase2Encryption(name: 'aes'); + $supported = $p2e->get_supported_keylens(); + $this->assert_is_true(in_array(128, $supported, true)); $this->assert_is_true(in_array(256, $supported, true)); $this->assert_is_false(in_array(257, $supported, true)); @@ -48,8 +42,8 @@ class APIModelsIPsecPhase2EncryptionTestCase extends TestCase { # Ensure an error is not thrown if `get_supported_keylens()` receives a valid `name` $this->assert_does_not_throw( callable: function () { - $keylen_enabled_algo = IPsecPhase2Encryption::get_keylen_enabled_algos()[0]; - IPsecPhase2Encryption::get_supported_keylens(name: $keylen_enabled_algo); + $p2e = new IPsecPhase2Encryption(name: 'aes'); + $p2e->get_supported_keylens(); }, ); @@ -58,7 +52,8 @@ class APIModelsIPsecPhase2EncryptionTestCase extends TestCase { response_id: 'IPSEC_PHASE_2_ENCRYPTION_COULD_NOT_GET_KEYLENS_FOR_UNKNOWN_ALGO', code: 400, callable: function () { - IPsecPhase2Encryption::get_supported_keylens(name: 'not a valid encryption algorithm'); + $p2e = new IPsecPhase2Encryption(name: 'not a valid encryption algorithm'); + $p2e->get_supported_keylens(); }, ); } @@ -70,10 +65,19 @@ class APIModelsIPsecPhase2EncryptionTestCase extends TestCase { public function test_get_supported_keylens(): void { global $p2_ealgos; + $p2e = new IPsecPhase2Encryption(); + # Loop each encryption algorithm that supported variable key lengths foreach (IPsecPhase2Encryption::get_keylen_enabled_algos() as $algo) { + $p2e->name->value = $algo; + # Loop through each key length option available to this algorithm and ensure it meets the correct parameters - foreach (IPsecPhase2Encryption::get_supported_keylens(name: $algo) as $keylen) { + foreach ($p2e->get_supported_keylens() as $keylen) { + # Skip 0 as it's always considered valid + if ($keylen === 0) { + continue; + } + # Obtain the parameters for supported key lengths $min_keylen = $p2_ealgos[$algo]['keysel']['lo']; $max_keylen = $p2_ealgos[$algo]['keysel']['hi'];