diff --git a/src/Control/Controller.php b/src/Control/Controller.php index 891c8d332ab..32320fdb7e4 100644 --- a/src/Control/Controller.php +++ b/src/Control/Controller.php @@ -527,30 +527,10 @@ public function render($params = null): DBHTMLText /** * Returns the current controller. - * - * @return Controller - */ - public static function curr() - { - if (Controller::$controller_stack) { - return Controller::$controller_stack[0]; - } - // This user_error() will be removed in the next major version of Silverstripe CMS - user_error("No current controller available", E_USER_WARNING); - return null; - } - - /** - * Tests whether we have a currently active controller or not. True if there is at least 1 - * controller in the stack. - * - * @return bool - * @deprecated 5.4.0 Will be removed without equivalent functionality to replace it */ - public static function has_curr() + public static function curr(): ?Controller { - Deprecation::noticeWithNoReplacment('5.4.0'); - return Controller::$controller_stack ? true : false; + return Controller::$controller_stack[0] ?? null; } /** diff --git a/src/Control/Cookie.php b/src/Control/Cookie.php index 2bcafa606de..1ccf455b88b 100644 --- a/src/Control/Cookie.php +++ b/src/Control/Cookie.php @@ -133,10 +133,7 @@ public static function validateSameSite(string $sameSite): void */ private static function getRequest(): ?HTTPRequest { - $request = null; - if (Controller::has_curr()) { - $request = Controller::curr()->getRequest(); - } + $request = Controller::curr()?->getRequest(); // NullHTTPRequest always has a scheme of http - set to null so we can fallback on default_base_url return ($request instanceof NullHTTPRequest) ? null : $request; } diff --git a/src/Control/Middleware/AllowedHostsMiddleware.php b/src/Control/Middleware/AllowedHostsMiddleware.php index 8a7df32f4c7..7915dc8b139 100644 --- a/src/Control/Middleware/AllowedHostsMiddleware.php +++ b/src/Control/Middleware/AllowedHostsMiddleware.php @@ -2,6 +2,7 @@ namespace SilverStripe\Control\Middleware; +use InvalidArgumentException; use SilverStripe\Control\Director; use SilverStripe\Control\HTTPRequest; use SilverStripe\Control\HTTPResponse; @@ -12,14 +13,16 @@ class AllowedHostsMiddleware implements HTTPMiddleware { /** - * List of allowed hosts + * List of allowed hosts. + * Can be ['*'] to allow all hosts and disable the logged warning. * * @var array */ private $allowedHosts = []; /** - * @return array List of allowed Host header values + * @return array List of allowed Host header values. + * Note that both an empty array and ['*'] can be used to allow all hosts. */ public function getAllowedHosts() { @@ -30,14 +33,21 @@ public function getAllowedHosts() * Sets the list of allowed Host header values * Can also specify a comma separated list * + * Note that both an empty array and ['*'] can be used to allow all hosts. + * * @param array|string $allowedHosts * @return $this */ public function setAllowedHosts($allowedHosts) { - if (is_string($allowedHosts)) { + if ($allowedHosts === null) { + $allowedHosts = []; + } elseif (is_string($allowedHosts)) { $allowedHosts = preg_split('/ *, */', $allowedHosts ?? ''); } + if (count($allowedHosts) > 1 && in_array('*', $allowedHosts)) { + throw new InvalidArgumentException('The wildcard "*" cannot be used in conjunction with actual hosts.'); + } $this->allowedHosts = $allowedHosts; return $this; } @@ -51,6 +61,7 @@ public function process(HTTPRequest $request, callable $delegate) // check allowed hosts if ($allowedHosts + && $allowedHosts !== ['*'] && !Director::is_cli() && !in_array($request->getHeader('Host'), $allowedHosts ?? []) ) { diff --git a/src/Core/BaseKernel.php b/src/Core/BaseKernel.php index d3c0201b9f5..2c4cee151dd 100644 --- a/src/Core/BaseKernel.php +++ b/src/Core/BaseKernel.php @@ -10,6 +10,7 @@ use SilverStripe\Control\Director; use SilverStripe\Control\HTTPResponse; use SilverStripe\Control\HTTPResponse_Exception; +use SilverStripe\Control\Middleware\AllowedHostsMiddleware; use SilverStripe\Core\Cache\ManifestCacheFactory; use SilverStripe\Core\Config\ConfigLoader; use SilverStripe\Core\Config\CoreConfigFactory; @@ -221,6 +222,9 @@ protected function bootConfigs() { // After loading all other app manifests, include _config.php files $this->getModuleLoader()->getManifest()->activateConfig(); + + // Ensure everything is set up correctly + $this->validateConfiguration(); } /** @@ -362,6 +366,7 @@ public function activate() $this->getInjectorLoader() ->getManifest() ->registerService($this, Kernel::class); + return $this; } @@ -443,4 +448,27 @@ public function setThemeResourceLoader($themeResourceLoader) $this->themeResourceLoader = $themeResourceLoader; return $this; } + + /** + * Validate configuration of the application is in a good state, ready for use. + * + * This method can be used to warn developers of any misconfiguration, or configuration + * which is missing but should be set according to best practice. + * + * In some cases, this could be used to halt execution if configuration critical to operation + * has not been set. + */ + protected function validateConfiguration(): void + { + // Log a warning if allowed hosts hasn't been configured. + // This can include wildcard, but it must be explicitly set to ensure the developer is aware + // of the level of protection their application has against host header injection attacks. + $allowedHostsMiddleware = Injector::inst()->get(AllowedHostsMiddleware::class, true); + if (empty($allowedHostsMiddleware->getAllowedHosts())) { + Injector::inst()->get(LoggerInterface::class)->warning( + 'Allowed hosts has not been set. Your application could be vulnerable to host header injection attacks.' + . ' Either set the SS_ALLOWED_HOSTS environment variable or the AllowedHosts property on ' . AllowedHostsMiddleware::class + ); + } + } } diff --git a/src/Core/Validation/FieldValidation/BigIntFieldValidator.php b/src/Core/Validation/FieldValidation/BigIntFieldValidator.php index ee9d8c5b12b..646caf1ed08 100644 --- a/src/Core/Validation/FieldValidation/BigIntFieldValidator.php +++ b/src/Core/Validation/FieldValidation/BigIntFieldValidator.php @@ -4,6 +4,7 @@ use RunTimeException; use SilverStripe\Core\Validation\ValidationResult; +use SilverStripe\ORM\FieldType\DBBigInt; /** * A field validator for 64-bit integers @@ -11,21 +12,6 @@ */ class BigIntFieldValidator extends IntFieldValidator { - /** - * The minimum value for a signed 64-bit integer. - * Defined as string instead of int otherwise will end up as a float - * on 64-bit systems - * - * When this is cast to an int in IntFieldValidator::__construct() - * it will be properly cast to an int - */ - protected const MIN_INT = '-9223372036854775808'; - - /** - * The maximum value for a signed 64-bit integer. - */ - protected const MAX_INT = '9223372036854775807'; - public function __construct( string $name, mixed $value, @@ -37,6 +23,8 @@ public function __construct( if ($bits === 32) { throw new RunTimeException('Cannot use BigIntFieldValidator on a 32-bit system'); } + $minValue ??= (int) DBBigInt::getMinValue(); + $maxValue ??= (int) DBBigInt::getMaxValue(); } $this->minValue = $minValue; $this->maxValue = $maxValue; @@ -51,10 +39,10 @@ protected function validateValue(): ValidationResult // int values that are too large or too small will be cast to float // on 64-bit systems and will fail the validation in IntFieldValidator if (is_string($this->value)) { - if (!is_null($this->minValue) && bccomp($this->value, static::MIN_INT) === -1) { + if (!is_null($this->minValue) && bccomp($this->value, DBBigInt::getMinValue()) === -1) { $result->addFieldError($this->name, $this->getTooSmallMessage()); } - if (!is_null($this->maxValue) && bccomp($this->value, static::MAX_INT) === 1) { + if (!is_null($this->maxValue) && bccomp($this->value, DBBigInt::getMaxValue()) === 1) { $result->addFieldError($this->name, $this->getTooLargeMessage()); } } diff --git a/src/Core/Validation/FieldValidation/IntFieldValidator.php b/src/Core/Validation/FieldValidation/IntFieldValidator.php index 28c677b036d..95701d03b7d 100644 --- a/src/Core/Validation/FieldValidation/IntFieldValidator.php +++ b/src/Core/Validation/FieldValidation/IntFieldValidator.php @@ -3,24 +3,13 @@ namespace SilverStripe\Core\Validation\FieldValidation; use SilverStripe\Core\Validation\ValidationResult; +use SilverStripe\ORM\FieldType\DBInt; /** * Validates that a value is a 32-bit signed integer */ class IntFieldValidator extends NumericNonStringFieldValidator { - /** - * The minimum value for a signed 32-bit integer. - * Defined as string instead of int because be cast to a float - * on 32-bit systems if defined as an int - */ - protected const MIN_INT = '-2147483648'; - - /** - * The maximum value for a signed 32-bit integer. - */ - protected const MAX_INT = '2147483647'; - public function __construct( string $name, mixed $value, @@ -28,10 +17,10 @@ public function __construct( ?int $maxValue = null ) { if (is_null($minValue)) { - $minValue = (int) static::MIN_INT; + $minValue = (int) DBInt::getMinValue(); } if (is_null($maxValue)) { - $maxValue = (int) static::MAX_INT; + $maxValue = (int) DBInt::getMaxValue(); } parent::__construct($name, $value, $minValue, $maxValue); } diff --git a/src/Dev/SapphireTest.php b/src/Dev/SapphireTest.php index 9bc482f5542..f78546f229f 100644 --- a/src/Dev/SapphireTest.php +++ b/src/Dev/SapphireTest.php @@ -597,7 +597,7 @@ protected function tearDown(): void // Note: Ideally a clean Controller should be created for each test. // Now all tests executed in a batch share the same controller. if (class_exists(Controller::class)) { - $controller = Controller::has_curr() ? Controller::curr() : null; + $controller = Controller::curr(); if ($controller && ($response = $controller->getResponse()) && $response->getHeader('Location')) { $response->setStatusCode(200); $response->removeHeader('Location'); diff --git a/src/Dev/TestSession.php b/src/Dev/TestSession.php index 215af9adc8d..88f6cee7193 100644 --- a/src/Dev/TestSession.php +++ b/src/Dev/TestSession.php @@ -71,11 +71,11 @@ public function __destruct() { // Shift off anything else that's on the stack. This can happen if something throws // an exception that causes a premature TestSession::__destruct() call - while (Controller::has_curr() && Controller::curr() !== $this->controller) { + while (Controller::curr() && Controller::curr() !== $this->controller) { Controller::curr()->popCurrent(); } - if (Controller::has_curr()) { + if (Controller::curr()) { $this->controller->popCurrent(); } } diff --git a/src/Forms/CurrencyField.php b/src/Forms/CurrencyField.php index 4546075dff3..5a743b3cd1f 100644 --- a/src/Forms/CurrencyField.php +++ b/src/Forms/CurrencyField.php @@ -30,6 +30,7 @@ public function setValue($value, $data = null) . number_format((double)preg_replace('/[^0-9.\-]/', '', $value ?? ''), 2); return $this; } + /** * Overwrite the datavalue before saving to the db ;-) * return 0.00 if no value, or value is non-numeric diff --git a/src/Forms/Form.php b/src/Forms/Form.php index 8d2924cc198..9dfe2aed586 100644 --- a/src/Forms/Form.php +++ b/src/Forms/Form.php @@ -3,6 +3,7 @@ namespace SilverStripe\Forms; use BadMethodCallException; +use SilverStripe\Admin\LeftAndMain; use SilverStripe\Control\Controller; use SilverStripe\Control\HasRequestHandler; use SilverStripe\Control\HTTPRequest; @@ -22,6 +23,8 @@ use SilverStripe\View\SSViewer; use SilverStripe\Model\ModelData; use SilverStripe\Forms\Validation\Validator; +use SilverStripe\Security\SudoMode\SudoModeServiceInterface; +use SilverStripe\Forms\GridField\GridFieldDetailForm_ItemRequest; /** * Base class for all forms. @@ -320,6 +323,46 @@ public function __construct( $this->setupDefaultClasses(); } + /** + * Make the form require sudo mode, which will make the form readonly and add a sudo mode password field + * unless the current user previously activated sudo mode. + * + * Note that if the parent request handler for this form isn't LeftAndMain or GridFieldDetailForm_ItemRequest, + * sudo mode will not be required by this form. + */ + public function requireSudoMode(): void + { + // Check that the current request handler for the form is one that's used + // in an admin context where sudo mode makes sense + $classes = [ + LeftAndMain::class, + GridFieldDetailForm_ItemRequest::class, + ]; + $enableSudoMode = false; + foreach ($classes as $class) { + if (is_a($this->getController(), $class)) { + $enableSudoMode = true; + break; + } + } + if (!$enableSudoMode) { + return; + } + // Check if sudo mode is currently enabled + $service = Injector::inst()->get(SudoModeServiceInterface::class); + $session = $this->getRequest()->getSession(); + if ($service->check($session)) { + return; + } + // If sudo mode is not active, make the form readonly and add a sudo mode password field + $this->makeReadonly(); + $field = SudoModePasswordField::create(SudoModePasswordField::FIELD_NAME); + // Manually call setForm() to the field as the field list is being updated after the + // form is created, which is when setForm() is normally being created + $field->setForm($this); + $this->Fields()->unshift($field); + } + /** * @return bool */ @@ -384,7 +427,7 @@ protected function getRequest() return $controller->getRequest(); } // Fall back to current controller - if (Controller::has_curr() && !(Controller::curr()->getRequest() instanceof NullHTTPRequest)) { + if (Controller::curr() && !(Controller::curr()->getRequest() instanceof NullHTTPRequest)) { return Controller::curr()->getRequest(); } return null; diff --git a/src/Forms/GridField/GridField.php b/src/Forms/GridField/GridField.php index d72d19db62c..fefd52d14fd 100644 --- a/src/Forms/GridField/GridField.php +++ b/src/Forms/GridField/GridField.php @@ -16,7 +16,6 @@ use SilverStripe\Core\Injector\Injector; use SilverStripe\Forms\Form; use SilverStripe\Forms\FormField; -use SilverStripe\Forms\GridField\FormAction\SessionStore; use SilverStripe\Forms\GridField\FormAction\StateStore; use SilverStripe\Model\List\ArrayList; use SilverStripe\ORM\DataList; @@ -25,6 +24,9 @@ use SilverStripe\Model\List\SS_List; use SilverStripe\View\HTML; use SilverStripe\Model\ModelData; +use SilverStripe\Security\SudoMode\SudoModeServiceInterface; +use SilverStripe\ORM\DataObject; +use SilverStripe\Forms\GridField\GridFieldViewButton; /** * Displays a {@link SS_List} in a grid format. @@ -475,7 +477,7 @@ private function initState(): void private function addStateFromRequest(): void { $request = $this->getRequest(); - if (($request instanceof NullHTTPRequest) && Controller::has_curr()) { + if (($request instanceof NullHTTPRequest) && Controller::curr()) { $request = Controller::curr()->getRequest(); } @@ -526,6 +528,31 @@ public function FieldHolder($properties = []) { $this->extend('onBeforeRenderHolder', $this, $properties); + // Set GridField to read-only if sudo mode is required for the DataObject being managed + // and sudo mode is not active + $sudoModeTransformation = false; + $modelClass = null; + try { + $modelClass = $this->getModelClass(); + } catch (LogicException) { + // noop - it's possible to have a gridfield with custom components that don't rely on columns + // from the records in the list. + } + if (is_a($modelClass, DataObject::class, true)) { + /** @var DataObject $obj */ + $obj = Injector::inst()->create($modelClass); + if ($obj->getRequireSudoMode()) { + $session = Controller::curr()?->getRequest()?->getSession(); + if ($session) { + $service = Injector::inst()->get(SudoModeServiceInterface::class); + if (!$service->check($session)) { + $this->performReadonlyTransformation(); + $sudoModeTransformation = true; + } + } + } + } + $columns = $this->getColumns(); $list = $this->getManipulatedList(); @@ -557,6 +584,13 @@ public function FieldHolder($properties = []) if ($item instanceof GridFieldPaginator) { $total = $item->getTotalItems(); } + if ($sudoModeTransformation) { + // Modify the GridFieldViewButton on any GridFields so that it doesn't suffix the view URL with 'view' + // This allows us to gracefully reload a form in readonly mode when sudo mode is activated + if ($item instanceof GridFieldViewButton) { + $item->setSuffixViewToUrl(false); + } + } } foreach ($content as $contentKey => $contentValue) { diff --git a/src/Forms/GridField/GridFieldDetailForm_ItemRequest.php b/src/Forms/GridField/GridFieldDetailForm_ItemRequest.php index 1ff678b2074..4accd756207 100644 --- a/src/Forms/GridField/GridFieldDetailForm_ItemRequest.php +++ b/src/Forms/GridField/GridFieldDetailForm_ItemRequest.php @@ -323,7 +323,15 @@ public function ItemEditForm() if ($cb) { $cb($form, $this); } + $this->extend("updateItemEditForm", $form); + + // Check if the the record is a DataObject and if that DataObject requires sudo mode + // If so then require sudo mode for the item edit form + if (is_a($this->record, DataObject::class) && $this->record->getRequireSudoMode()) { + $form->requireSudoMode(); + } + return $form; } diff --git a/src/Forms/GridField/GridFieldFilterHeader.php b/src/Forms/GridField/GridFieldFilterHeader.php index 0cd2908a180..e84f1f34e2d 100755 --- a/src/Forms/GridField/GridFieldFilterHeader.php +++ b/src/Forms/GridField/GridFieldFilterHeader.php @@ -116,9 +116,13 @@ public function handleAction(GridField $gridField, $actionName, $arguments, $dat $state->Columns = []; if ($actionName === 'filter') { - if (isset($data['filter'][$gridField->getName()])) { - foreach ($data['filter'][$gridField->getName()] as $key => $filter) { - $state->Columns->$key = $filter; + $filterValues = $data['filter'][$gridField->getName()] ?? null; + if ($filterValues !== null) { + $form = $this->getSearchForm($gridField); + $this->removeSearchPrefixFromFields($form->Fields()); + $form->loadDataFrom($filterValues); + foreach ($filterValues as $fieldName => $rawFilterValue) { + $state->Columns->$fieldName = $form->Fields()->dataFieldByName($fieldName)?->dataValue() ?? $rawFilterValue; } } } @@ -498,6 +502,16 @@ private function addSearchPrefixToFields(FieldList $fields): void } } + private function removeSearchPrefixFromFields(FieldList $fields): void + { + foreach ($fields as $field) { + $field->setName(str_replace('Search__', '', $field->getName())); + if ($field instanceof CompositeField) { + $this->removeSearchPrefixFromFields($field->getChildren()); + } + } + } + /** * Update CSS classes for form fields, including nested inside composite fields */ diff --git a/src/Forms/GridField/GridFieldViewButton.php b/src/Forms/GridField/GridFieldViewButton.php index 58a9ad2d4f0..744ea293a47 100644 --- a/src/Forms/GridField/GridFieldViewButton.php +++ b/src/Forms/GridField/GridFieldViewButton.php @@ -13,6 +13,19 @@ */ class GridFieldViewButton extends AbstractGridFieldComponent implements GridField_ColumnProvider, GridField_ActionMenuLink { + private bool $suffixViewToUrl = true; + + public function getSuffixViewToUrl(): bool + { + return $this->suffixViewToUrl; + } + + public function setSuffixViewToUrl(bool $suffixViewToUrl): static + { + $this->suffixViewToUrl = $suffixViewToUrl; + return $this; + } + /** * @inheritdoc */ @@ -44,7 +57,11 @@ public function getExtraData($gridField, $record, $columnName) */ public function getUrl($gridField, $record, $columnName) { - $link = Controller::join_links($gridField->Link('item'), $record->ID, 'view'); + $parts = [$gridField->Link('item'), $record->ID]; + if ($this->getSuffixViewToUrl()) { + $parts[] = 'view'; + } + $link = Controller::join_links(...$parts); return $gridField->addAllStateToUrl($link); } diff --git a/src/Forms/SudoModePasswordField.php b/src/Forms/SudoModePasswordField.php new file mode 100644 index 00000000000..66f79e8317b --- /dev/null +++ b/src/Forms/SudoModePasswordField.php @@ -0,0 +1,29 @@ +setTitle(''); + $this->addExtraClass('SudoModePasswordField'); + } + + public function performReadonlyTransformation() + { + // Readonly transformation should not be applied to this field + // as this field is intended to be used on a form that has been set to read only mode + return $this; + } +} diff --git a/src/Logging/ErrorOutputHandler.php b/src/Logging/ErrorOutputHandler.php index 1813e7259f0..bdb0f4a564f 100644 --- a/src/Logging/ErrorOutputHandler.php +++ b/src/Logging/ErrorOutputHandler.php @@ -169,7 +169,7 @@ protected function write(LogRecord $record): void return; } - if (Controller::has_curr()) { + if (Controller::curr()) { $response = Controller::curr()->getResponse(); } else { $response = new HTTPResponse(); diff --git a/src/Model/ModelData.php b/src/Model/ModelData.php index f99e4b9d78b..b9af69ccaca 100644 --- a/src/Model/ModelData.php +++ b/src/Model/ModelData.php @@ -65,6 +65,13 @@ class ModelData */ private array $dynamicData = []; + /** + * Config of whether the model requires sudo mode to be active in order to be modified in admin + * Sudo mode is a security feature that requires the user to re-enter their password before + * making changes to the database. + */ + private static bool $require_sudo_mode = false; + // ----------------------------------------------------------------------------------------------------------------- /** @@ -218,6 +225,14 @@ public function hasDynamicData(string $field): bool return array_key_exists($field, $this->dynamicData); } + /** + * Whether the model requires sudo mode to be active in order to be modified in admin + */ + public function getRequireSudoMode(): bool + { + return static::config()->get('require_sudo_mode'); + } + /** * Returns true if a method exists for the current class which isn't private. * Also returns true for private methods if $this is ModelData (not a subclass) diff --git a/src/ORM/DataObject.php b/src/ORM/DataObject.php index 6fb7ad2d0e4..b6b2062a7c0 100644 --- a/src/ORM/DataObject.php +++ b/src/ORM/DataObject.php @@ -16,6 +16,7 @@ use SilverStripe\Core\Validation\ValidationResult; use SilverStripe\Dev\Debug; use SilverStripe\Dev\Deprecation; +use SilverStripe\Forms\FieldGroup; use SilverStripe\Forms\FieldList; use SilverStripe\Forms\FormField; use SilverStripe\Forms\FormScaffolder; @@ -46,6 +47,7 @@ use SilverStripe\Security\Security; use SilverStripe\View\SSViewer; use SilverStripe\Model\ModelData; +use SilverStripe\ORM\Filters\WithinRangeFilter; use stdClass; /** @@ -2438,40 +2440,7 @@ public function scaffoldSearchFields($_params = null) continue; } - // If a custom fieldclass is provided as a string, use it - $field = null; - if ($params['fieldClasses'] && isset($params['fieldClasses'][$fieldName])) { - $fieldClass = $params['fieldClasses'][$fieldName]; - $field = new $fieldClass($fieldName); - // If we explicitly set a field, then construct that - } elseif (isset($spec['field'])) { - // If it's a string, use it as a class name and construct - if (is_string($spec['field'])) { - $fieldClass = $spec['field']; - $field = new $fieldClass($fieldName); - - // If it's a FormField object, then just use that object directly. - } elseif ($spec['field'] instanceof FormField) { - $field = $spec['field']; - - // Otherwise we have a bug - } else { - user_error("Bad value for searchable_fields, 'field' value: " - . var_export($spec['field'], true), E_USER_WARNING); - } - - // Otherwise, use the database field's scaffolder - } elseif ($object = $this->relObject($fieldName)) { - if (is_object($object) && $object->hasMethod('scaffoldSearchField')) { - $field = $object->scaffoldSearchField(); - } else { - throw new Exception(sprintf( - "SearchField '%s' on '%s' does not return a valid DBField instance.", - $fieldName, - get_class($this) - )); - } - } + $field = $this->scaffoldSingleSearchField($fieldName, $spec, $params); // Allow fields to opt out of search if (!$field) { @@ -2483,6 +2452,24 @@ public function scaffoldSearchFields($_params = null) } $field->setTitle($spec['title']); + // If we're using a WithinRangeFilter, split the field into two separate fields (from and to) + if (is_a($spec['filter'] ?? '', WithinRangeFilter::class, true)) { + $fieldFrom = $field; + $fieldTo = clone $field; + $originalTitle = $field->Title(); + $originalName = $field->getName(); + + $fieldFrom->setName($originalName . '_SearchFrom'); + $fieldFrom->setTitle(_t(__CLASS__ . '.FILTER_WITHINRANGE_FROM', 'From')); + $fieldTo->setName($originalName . '_SearchTo'); + $fieldTo->setTitle(_t(__CLASS__ . '.FILTER_WITHINRANGE_TO', 'To')); + + $field = FieldGroup::create( + $originalTitle, + [$fieldFrom, $fieldTo] + )->setName($originalName)->addExtraClass('fieldgroup--fill-width'); + } + $fields->push($field); } @@ -2498,6 +2485,56 @@ public function scaffoldSearchFields($_params = null) return $fields; } + /** + * Scaffold a single form field for use by the search context form. + */ + private function scaffoldSingleSearchField(string $fieldName, array $spec, ?array $params): ?FormField + { + // If a custom fieldclass is provided as a string, use it + $field = null; + if ($params['fieldClasses'] && isset($params['fieldClasses'][$fieldName])) { + $fieldClass = $params['fieldClasses'][$fieldName]; + $field = new $fieldClass($fieldName); + // If we explicitly set a field, then construct that + } elseif (isset($spec['field'])) { + // If it's a string, use it as a class name and construct + if (is_string($spec['field'])) { + $fieldClass = $spec['field']; + $field = new $fieldClass($fieldName); + + // If it's a FormField object, then just use that object directly. + } elseif ($spec['field'] instanceof FormField) { + $field = $spec['field']; + + // Otherwise we have a bug + } else { + user_error("Bad value for searchable_fields, 'field' value: " + . var_export($spec['field'], true), E_USER_WARNING); + } + // Use the explicitly defined dataType if one was set + } elseif (isset($spec['dataType'])) { + $object = Injector::inst()->get($spec['dataType'], true); + $field = $this->scaffoldFieldFromObject($object, $fieldName); + $field->setName($fieldName); + // Otherwise, use the database field's scaffolder + } elseif ($object = $this->relObject($fieldName)) { + $field = $this->scaffoldFieldFromObject($object, $fieldName); + } + return $field; + } + + private function scaffoldFieldFromObject(mixed $object, string $fieldName): FormField + { + if (!is_object($object) || !$object->hasMethod('scaffoldSearchField')) { + throw new LogicException(sprintf( + "SearchField '%s' on '%s' does not return a valid DBField instance.", + $fieldName, + get_class($this) + )); + } + return $object->scaffoldSearchField(); + } + /** * Scaffold a simple edit form for all properties on this dataobject, * based on default {@link FormField} mapping in {@link DBField::scaffoldFormField()}. @@ -3896,6 +3933,7 @@ public function searchableFields() $rewrite = []; foreach ($fields as $name => $specOrName) { $identifier = (is_int($name)) ? $specOrName : $name; + $relObject = isset($specOrName['match_any']) ? null : $this->relObject($identifier); if (is_int($name)) { // Format: array('MyFieldName') @@ -3903,14 +3941,22 @@ public function searchableFields() } elseif (is_array($specOrName) && (isset($specOrName['match_any']))) { $rewrite[$identifier] = $fields[$identifier]; $rewrite[$identifier]['match_any'] = $specOrName['match_any']; - } elseif (is_array($specOrName) && ($relObject = $this->relObject($identifier))) { + } elseif (is_array($specOrName)) { // Format: array('MyFieldName' => array( // 'filter => 'ExactMatchFilter', // 'field' => 'NumericField', // optional // 'title' => 'My Title', // optional + // 'dataType' => DBInt::class // optional + // These two are only required if using WithinRangeFilter with a data type that doesn't + // inherently represent a date, time, or number + // 'rangeFromDefault' => PHP_INT_MIN + // 'rangeToDefault' => PHP_INT_MAX // )) $rewrite[$identifier] = array_merge( - ['filter' => $relObject->config()->get('default_search_filter_class')], + [ + 'filter' => $relObject?->config()->get('default_search_filter_class'), + 'dataType' => $relObject ? get_class($relObject) : null, + ], (array)$specOrName ); } else { @@ -3918,6 +3964,9 @@ public function searchableFields() $rewrite[$identifier] = [ 'filter' => $specOrName, ]; + if ($relObject !== null) { + $rewrite[$identifier]['dataType'] ??= get_class($relObject); + } } if (!isset($rewrite[$identifier]['title'])) { $rewrite[$identifier]['title'] = (isset($labels[$identifier])) @@ -3926,6 +3975,33 @@ public function searchableFields() if (!isset($rewrite[$identifier]['filter'])) { $rewrite[$identifier]['filter'] = 'PartialMatchFilter'; } + // When using a WithinRangeFilter we need to know what the default from and to values + // should be, so that if a user only enters one of the two fields the other can be + // populated appropriately within the filter. + if (is_a($rewrite[$identifier]['filter'], WithinRangeFilter::class, true)) { + // The dataType requirement here is explicitly for WithinRangeFilter. + // DO NOT make it mandatory for other filters without first checking if this breaks + // anything for filtering a relation, where the class on the other end of the relation + // implements scaffoldSearchField(). + $dataType = $rewrite[$identifier]['dataType'] ?? null; + if (!is_a($dataType ?? '', DBField::class, true)) { + throw new LogicException("dataType must be set to a DBField class for '$identifier'"); + } + if (!isset($rewrite[$identifier]['rangeFromDefault'])) { + $fromDefault = $dataType::getMinValue(); + if ($fromDefault === null) { + throw new LogicException("rangeFromDefault must be set for '$identifier'"); + } + $rewrite[$identifier]['rangeFromDefault'] = $fromDefault; + } + if (!isset($rewrite[$identifier]['rangeToDefault'])) { + $toDefault = $dataType::getMaxValue(); + if ($toDefault === null) { + throw new LogicException("rangeToDefault must be set for '$identifier'"); + } + $rewrite[$identifier]['rangeToDefault'] = $toDefault; + } + } } $fields = $rewrite; diff --git a/src/ORM/FieldType/DBBigInt.php b/src/ORM/FieldType/DBBigInt.php index af888e8f0bf..c40b80399a3 100644 --- a/src/ORM/FieldType/DBBigInt.php +++ b/src/ORM/FieldType/DBBigInt.php @@ -14,6 +14,21 @@ */ class DBBigInt extends DBInt { + /** + * The minimum value for a signed 64-bit integer. + * Defined as string instead of int otherwise will end up as a float + * on 64-bit systems + * + * When this is cast to an int in IntFieldValidator::__construct() + * it will be properly cast to an int + */ + protected const MIN_INT = '-9223372036854775808'; + + /** + * The maximum value for a signed 64-bit integer. + */ + protected const MAX_INT = '9223372036854775807'; + private static array $field_validators = [ // Remove parent validator and add BigIntValidator instead IntFieldValidator::class => null, diff --git a/src/ORM/FieldType/DBCurrency.php b/src/ORM/FieldType/DBCurrency.php index aea5d3b3e39..e5b8ee647ec 100644 --- a/src/ORM/FieldType/DBCurrency.php +++ b/src/ORM/FieldType/DBCurrency.php @@ -4,6 +4,7 @@ use SilverStripe\Forms\CurrencyField; use SilverStripe\Forms\FormField; +use SilverStripe\Forms\NumericField; use SilverStripe\Model\ModelData; /** @@ -68,4 +69,9 @@ public function scaffoldFormField(?string $title = null, array $params = []): ?F { return CurrencyField::create($this->getName(), $title); } + + public function scaffoldSearchField(?string $title = null): ?FormField + { + return NumericField::create($this->getName(), $title); + } } diff --git a/src/ORM/FieldType/DBDate.php b/src/ORM/FieldType/DBDate.php index 39b84e7b01b..e8eb1a1000c 100644 --- a/src/ORM/FieldType/DBDate.php +++ b/src/ORM/FieldType/DBDate.php @@ -619,4 +619,14 @@ protected function explodeDateString($value) $parts[] = $matches['time']; return $parts; } + + public static function getMinValue(): string + { + return '0000-00-00'; + } + + public static function getMaxValue(): string + { + return '9999-12-31'; + } } diff --git a/src/ORM/FieldType/DBDatetime.php b/src/ORM/FieldType/DBDatetime.php index 0ba5a201b03..0ba47e72d9a 100644 --- a/src/ORM/FieldType/DBDatetime.php +++ b/src/ORM/FieldType/DBDatetime.php @@ -15,6 +15,7 @@ use SilverStripe\Model\ModelData; use SilverStripe\Core\Validation\FieldValidation\DateFieldValidator; use SilverStripe\Core\Validation\FieldValidation\DatetimeFieldValidator; +use SilverStripe\ORM\Tests\Search\SearchContextTest\WithinRangeFilterModel; /** * Represents a date-time field. @@ -369,4 +370,14 @@ public function getISOFormat(): string { return DBDatetime::ISO_DATETIME; } + + public static function getMinValue(): string + { + return '0000-00-00 00:00:00'; + } + + public static function getMaxValue(): string + { + return '9999-12-31 23:59:59'; + } } diff --git a/src/ORM/FieldType/DBDecimal.php b/src/ORM/FieldType/DBDecimal.php index 0a32e0aeaef..a07049109a9 100644 --- a/src/ORM/FieldType/DBDecimal.php +++ b/src/ORM/FieldType/DBDecimal.php @@ -135,4 +135,14 @@ public function prepValueForDB(mixed $value): array|float|int|null return (float) $value; } + + public static function getMinValue(): float + { + return PHP_FLOAT_MIN; + } + + public static function getMaxValue(): float + { + return PHP_FLOAT_MAX; + } } diff --git a/src/ORM/FieldType/DBEmail.php b/src/ORM/FieldType/DBEmail.php index 2c16c8ab6ba..d266d2d9584 100644 --- a/src/ORM/FieldType/DBEmail.php +++ b/src/ORM/FieldType/DBEmail.php @@ -6,6 +6,7 @@ use SilverStripe\ORM\FieldType\DBVarchar; use SilverStripe\Core\Validation\FieldValidation\EmailFieldValidator; use SilverStripe\Forms\FormField; +use SilverStripe\Forms\TextField; class DBEmail extends DBVarchar { @@ -19,4 +20,9 @@ public function scaffoldFormField(?string $title = null, array $params = []): ?F $field->setMaxLength($this->getSize()); return $field; } + + public function scaffoldSearchField(?string $title = null): ?FormField + { + return TextField::create($this->getName(), $title); + } } diff --git a/src/ORM/FieldType/DBField.php b/src/ORM/FieldType/DBField.php index 29e326c41c0..64cec20fa97 100644 --- a/src/ORM/FieldType/DBField.php +++ b/src/ORM/FieldType/DBField.php @@ -571,4 +571,20 @@ public function scalarValueOnly(): bool { return true; } + + /** + * @return mixed The minimum value for comparisons with this field - or null if that's not determinable. + */ + public static function getMinValue(): mixed + { + return null; + } + + /** + * @return mixed The maximum value for comparisons with this field - or null if that's not determinable. + */ + public static function getMaxValue(): mixed + { + return null; + } } diff --git a/src/ORM/FieldType/DBFloat.php b/src/ORM/FieldType/DBFloat.php index 367957a4242..5764dff8528 100644 --- a/src/ORM/FieldType/DBFloat.php +++ b/src/ORM/FieldType/DBFloat.php @@ -89,4 +89,14 @@ public function prepValueForDB(mixed $value): array|float|int|null return $value; } + + public static function getMinValue(): float + { + return PHP_FLOAT_MIN; + } + + public static function getMaxValue(): float + { + return PHP_FLOAT_MAX; + } } diff --git a/src/ORM/FieldType/DBForeignKey.php b/src/ORM/FieldType/DBForeignKey.php index 6f30348688f..1d6dceb2b90 100644 --- a/src/ORM/FieldType/DBForeignKey.php +++ b/src/ORM/FieldType/DBForeignKey.php @@ -2,14 +2,8 @@ namespace SilverStripe\ORM\FieldType; -use SilverStripe\Assets\File; -use SilverStripe\Assets\Image; -use SilverStripe\Core\Injector\Injector; use SilverStripe\Core\Validation\FieldValidation\IntFieldValidator; -use SilverStripe\Forms\FileHandleField; use SilverStripe\Forms\FormField; -use SilverStripe\Forms\SearchableDropdownField; -use SilverStripe\ORM\DataList; use SilverStripe\ORM\DataObject; use SilverStripe\Model\ModelData; @@ -78,7 +72,7 @@ public function setValue(mixed $value, null|array|ModelData $record = null, bool return parent::setValue($value, $record, $markChanged); } - public function getMinValue(): int + public static function getMinValue(): int { return 0; } diff --git a/src/ORM/FieldType/DBHTMLText.php b/src/ORM/FieldType/DBHTMLText.php index fb82d2f95e8..81de75e3182 100644 --- a/src/ORM/FieldType/DBHTMLText.php +++ b/src/ORM/FieldType/DBHTMLText.php @@ -186,7 +186,7 @@ public function scaffoldFormField(?string $title = null, array $params = []): ?F public function scaffoldSearchField(?string $title = null): ?FormField { - return new TextField($this->name, $title); + return TextField::create($this->name, $title); } /** diff --git a/src/ORM/FieldType/DBInt.php b/src/ORM/FieldType/DBInt.php index 3acaf0e70b1..c10751c75e1 100644 --- a/src/ORM/FieldType/DBInt.php +++ b/src/ORM/FieldType/DBInt.php @@ -13,6 +13,18 @@ */ class DBInt extends DBField { + /** + * The minimum value for a signed 32-bit integer. + * Defined as string instead of int because be cast to a float + * on 32-bit systems if defined as an int + */ + protected const MIN_INT = '-2147483648'; + + /** + * The maximum value for a signed 32-bit integer. + */ + protected const MAX_INT = '2147483647'; + private static array $field_validators = [ IntFieldValidator::class ]; @@ -89,4 +101,14 @@ public function prepValueForDB(mixed $value): array|int|null return (int)$value; } + + public static function getMinValue(): string|int + { + return static::MIN_INT; + } + + public static function getMaxValue(): string|int + { + return static::MAX_INT; + } } diff --git a/src/ORM/FieldType/DBPercentage.php b/src/ORM/FieldType/DBPercentage.php index 1cac39e38c1..b1e6b173775 100644 --- a/src/ORM/FieldType/DBPercentage.php +++ b/src/ORM/FieldType/DBPercentage.php @@ -39,12 +39,12 @@ public function __construct(?string $name = null, int $precision = 4) parent::__construct($name, $precision + 1, $precision); } - public function getMinValue(): float + public static function getMinValue(): float { return 0.0; } - public function getMaxValue(): float + public static function getMaxValue(): float { return 1.0; } diff --git a/src/ORM/FieldType/DBTime.php b/src/ORM/FieldType/DBTime.php index 5b44cc6fac3..476038f2083 100644 --- a/src/ORM/FieldType/DBTime.php +++ b/src/ORM/FieldType/DBTime.php @@ -181,4 +181,14 @@ public function getTimestamp(): int } return 0; } + + public static function getMinValue(): string + { + return '00:00:00'; + } + + public static function getMaxValue(): string + { + return '23:59:59'; + } } diff --git a/src/ORM/FieldType/DBUrl.php b/src/ORM/FieldType/DBUrl.php index ab9435c6555..cac84e4f2a6 100644 --- a/src/ORM/FieldType/DBUrl.php +++ b/src/ORM/FieldType/DBUrl.php @@ -5,6 +5,7 @@ use SilverStripe\ORM\FieldType\DBVarchar; use SilverStripe\Core\Validation\FieldValidation\UrlFieldValidator; use SilverStripe\Forms\FormField; +use SilverStripe\Forms\TextField; use SilverStripe\Forms\UrlField; class DBUrl extends DBVarchar @@ -19,4 +20,9 @@ public function scaffoldFormField(?string $title = null, array $params = []): ?F $field->setMaxLength($this->getSize()); return $field; } + + public function scaffoldSearchField(?string $title = null): ?FormField + { + return TextField::create($this->getName(), $title); + } } diff --git a/src/ORM/FieldType/DBYear.php b/src/ORM/FieldType/DBYear.php index 04c26c09dc4..0f05443b528 100644 --- a/src/ORM/FieldType/DBYear.php +++ b/src/ORM/FieldType/DBYear.php @@ -21,8 +21,8 @@ class DBYear extends DBField private static $field_validators = [ YearFieldValidator::class => [ - 'minValue' => 'getMinYear', - 'maxValue' => 'getMaxYear' + 'minValue' => 'getMinValue', + 'maxValue' => 'getMaxValue' ], ]; @@ -70,12 +70,12 @@ public function setValue(mixed $value, null|array|ModelData $record = null, bool return $this; } - public function getMinYear(): int + public static function getMinValue(): int { return DBYear::MIN_YEAR; } - public function getMaxYear(): int + public static function getMaxValue(): int { return DBYear::MAX_YEAR; } diff --git a/src/ORM/Filters/WithinRangeFilter.php b/src/ORM/Filters/WithinRangeFilter.php index 55f943e334c..0dd15671486 100644 --- a/src/ORM/Filters/WithinRangeFilter.php +++ b/src/ORM/Filters/WithinRangeFilter.php @@ -6,20 +6,29 @@ class WithinRangeFilter extends SearchFilter { + private mixed $min = null; + private mixed $max = null; - private $min; - private $max; - - public function setMin($min) + public function setMin(mixed $min) { $this->min = $min; } - public function setMax($max) + public function getMin(): mixed + { + return $this->min; + } + + public function setMax(mixed $max) { $this->max = $max; } + public function getMax(): mixed + { + return $this->max; + } + protected function applyOne(DataQuery $query) { $this->model = $query->applyRelation($this->relation); diff --git a/src/ORM/Hierarchy/Hierarchy.php b/src/ORM/Hierarchy/Hierarchy.php index d92f3588c6d..34893c51727 100644 --- a/src/ORM/Hierarchy/Hierarchy.php +++ b/src/ORM/Hierarchy/Hierarchy.php @@ -609,10 +609,10 @@ public function allowedChildren(): array */ public function showingCMSTree() { - if (!Controller::has_curr() || !class_exists(LeftAndMain::class)) { + $controller = Controller::curr(); + if (!$controller || !class_exists(LeftAndMain::class)) { return false; } - $controller = Controller::curr(); return $controller instanceof LeftAndMain && in_array($controller->getAction(), ["treeview", "listview", "getsubtree"]); } diff --git a/src/ORM/Search/SearchContext.php b/src/ORM/Search/SearchContext.php index 7fd9da243b2..d360027cefd 100644 --- a/src/ORM/Search/SearchContext.php +++ b/src/ORM/Search/SearchContext.php @@ -19,7 +19,10 @@ use Exception; use LogicException; use SilverStripe\Core\Config\Config; +use SilverStripe\Forms\DateField; use SilverStripe\ORM\DataQuery; +use SilverStripe\ORM\FieldType\DBDatetime; +use SilverStripe\ORM\Filters\WithinRangeFilter; /** * Manages searching of properties on one or more {@link DataObject} @@ -78,6 +81,13 @@ class SearchContext */ protected $searchParams = []; + /** + * A list of fields that use WithinRangeFilter that have already been included in the query. + * This prevents the "from" and "to" fields from both independently affecting the query since they + * have to be paired together in the same filter. + */ + protected $withinRangeFieldsChecked = []; + /** * A key value pair of values that should be searched for. * The keys should match the field names specified in {@link SearchContext::$fields}. @@ -161,6 +171,7 @@ public function getQuery($searchParams, $sort = false, int|array|null $limit = n */ private function search(DataList $query): DataList { + $this->withinRangeFieldsChecked = []; /** @var DataObject $modelObj */ $modelObj = Injector::inst()->create($this->modelClass); $searchableFields = $modelObj->searchableFields(); @@ -296,8 +307,35 @@ private function individualFieldSearch(DataList $query, array $searchableFields, return $query; } $filter->setModel($this->modelClass); - $filter->setValue($searchPhrase); - $searchableFieldSpec = $searchableFields[$searchField] ?? []; + // WithinRangeFilter needs a bit of help knowing what its "from" and "to" values are + if ($filter instanceof WithinRangeFilter) { + $baseName = preg_replace('/_Search(From|To)$/', '', $searchField); + if (array_key_exists($baseName, $this->withinRangeFieldsChecked)) { + return $query; + } + $allSearchParams = $this->getSearchParams(); + $searchableFieldSpec = $searchableFields[$baseName] ?? []; + $from = $allSearchParams[$baseName . '_SearchFrom'] ?? $searchableFieldSpec['rangeFromDefault']; + $to = $allSearchParams[$baseName . '_SearchTo'] ?? $searchableFieldSpec['rangeToDefault']; + // If we're using DateField for a DBDateTime, set "from" to the start of the day, and "to" to the end of the day. + // Though if we're using the default values, we don't need to add this as it should already be there. + if (is_a($searchableFieldSpec['dataType'] ?? '', DBDatetime::class, true) + && is_a($searchableFieldSpec['field'] ?? '', DateField::class, true) + ) { + if ($from !== $searchableFieldSpec['rangeFromDefault']) { + $from .= ' 00:00:00'; + } + if ($to !== $searchableFieldSpec['rangeToDefault']) { + $to .= ' 23:59:59'; + } + } + $filter->setMin($from); + $filter->setMax($to); + $this->withinRangeFieldsChecked[$baseName] = true; + } else { + $filter->setValue($searchPhrase); + $searchableFieldSpec = $searchableFields[$searchField] ?? []; + } return $query->alterDataQuery(function ($dataQuery) use ($filter, $searchableFieldSpec) { $this->applyFilter($filter, $dataQuery, $searchableFieldSpec); }); @@ -318,9 +356,13 @@ private function applyFilter(SearchFilter $filter, DataQuery $dataQuery, array $ $modifiers = $filter->getModifiers(); $subGroup = $dataQuery->disjunctiveGroup(); foreach ($searchFields as $matchField) { - /** @var SearchFilter $filter */ - $filter = Injector::inst()->create($filterClass, $matchField, $value, $modifiers); - $filter->apply($subGroup); + /** @var SearchFilter $subFilter */ + $subFilter = Injector::inst()->create($filterClass, $matchField, $value, $modifiers); + if ($subFilter instanceof WithinRangeFilter) { + $subFilter->setMin($filter->getMin()); + $subFilter->setMax($filter->getMax()); + } + $subFilter->apply($subGroup); } } else { $filter->apply($dataQuery); @@ -364,11 +406,8 @@ public function clearEmptySearchFields($value) */ public function getFilter($name) { - if (isset($this->filters[$name])) { - return $this->filters[$name]; - } else { - return null; - } + $withinRangeFilterCheck = preg_replace('/_Search(From|To)$/', '', $name); + return $this->filters[$name] ?? $this->filters[$withinRangeFilterCheck] ?? null; } /** diff --git a/src/Security/Group.php b/src/Security/Group.php index 6b5a59f1b6a..c420e07a8ab 100755 --- a/src/Security/Group.php +++ b/src/Security/Group.php @@ -96,6 +96,8 @@ class Group extends DataObject 'Code' => true, 'Sort' => true, ]; + + private static bool $require_sudo_mode = true; public function getAllChildren() { diff --git a/src/Security/Member.php b/src/Security/Member.php index 8276a526ef4..87cdfff9612 100644 --- a/src/Security/Member.php +++ b/src/Security/Member.php @@ -115,6 +115,8 @@ class Member extends DataObject //'AutoLoginHash' => Array('type'=>'unique', 'value'=>'AutoLoginHash', 'ignoreNulls'=>true) ]; + private static bool $require_sudo_mode = true; + /** * @config * @var boolean diff --git a/src/Security/MemberAuthenticator/SessionAuthenticationHandler.php b/src/Security/MemberAuthenticator/SessionAuthenticationHandler.php index 8d8e5ff69af..be10b4d5ef5 100644 --- a/src/Security/MemberAuthenticator/SessionAuthenticationHandler.php +++ b/src/Security/MemberAuthenticator/SessionAuthenticationHandler.php @@ -82,11 +82,6 @@ public function logIn(Member $member, $persistent = false, ?HTTPRequest $request if (Member::config()->get('login_marker_cookie')) { Cookie::set(Member::config()->get('login_marker_cookie'), 1, 0); } - - // Activate sudo mode on login so the user doesn't have to reauthenticate for sudo - // actions until the sudo mode timeout expires - $service = Injector::inst()->get(SudoModeServiceInterface::class); - $service->activate($session); } /** diff --git a/src/Security/Permission.php b/src/Security/Permission.php index 1c5d54b9163..235a850357e 100644 --- a/src/Security/Permission.php +++ b/src/Security/Permission.php @@ -102,6 +102,8 @@ class Permission extends DataObject implements TemplateGlobalProvider, Resettabl 'EDIT_PERMISSIONS' ]; + private static bool $require_sudo_mode = true; + /** * Check that the current member has the given permission. * diff --git a/src/Security/PermissionRole.php b/src/Security/PermissionRole.php index ccfc02fcaf7..b2dec04d7fc 100644 --- a/src/Security/PermissionRole.php +++ b/src/Security/PermissionRole.php @@ -48,6 +48,8 @@ class PermissionRole extends DataObject private static $plural_name = 'Roles'; + private static bool $require_sudo_mode = true; + public function getCMSFields() { $fields = parent::getCMSFields(); diff --git a/src/Security/PermissionRoleCode.php b/src/Security/PermissionRoleCode.php index 2383e926838..7f9a0c4df99 100644 --- a/src/Security/PermissionRoleCode.php +++ b/src/Security/PermissionRoleCode.php @@ -26,6 +26,8 @@ class PermissionRoleCode extends DataObject private static $table_name = "PermissionRoleCode"; private static bool $must_use_primary_db = true; + + private static bool $require_sudo_mode = true; private static $indexes = [ "Code" => true, diff --git a/src/Security/Security.php b/src/Security/Security.php index ee69e1b1cfb..8103b1ee564 100644 --- a/src/Security/Security.php +++ b/src/Security/Security.php @@ -326,7 +326,7 @@ public static function permissionFailure($controller = null, $messageSet = null) ]; }; - if (!$controller && Controller::has_curr()) { + if (!$controller && Controller::curr()) { $controller = Controller::curr(); } @@ -526,8 +526,9 @@ public function getRequest() return $request; } - if (Controller::has_curr() && Controller::curr() !== $this) { - return Controller::curr()->getRequest(); + $controller = Controller::curr(); + if ($controller && $controller !== $this) { + return $controller->getRequest(); } return null; diff --git a/src/Security/SecurityToken.php b/src/Security/SecurityToken.php index 7c7ce6b9014..acf6999c2c3 100644 --- a/src/Security/SecurityToken.php +++ b/src/Security/SecurityToken.php @@ -184,7 +184,7 @@ protected function getSession() $injector = Injector::inst(); if ($injector->has(HTTPRequest::class)) { return $injector->get(HTTPRequest::class)->getSession(); - } elseif (Controller::has_curr()) { + } elseif (Controller::curr()) { return Controller::curr()->getRequest()->getSession(); } throw new Exception('No HTTPRequest object or controller available yet!'); diff --git a/src/Security/SudoMode/SudoModeService.php b/src/Security/SudoMode/SudoModeService.php index 74fa8547fe4..6a0e158f65b 100644 --- a/src/Security/SudoMode/SudoModeService.php +++ b/src/Security/SudoMode/SudoModeService.php @@ -4,11 +4,13 @@ use SilverStripe\Control\Session; use SilverStripe\Core\Config\Configurable; +use SilverStripe\Core\Extensible; use SilverStripe\ORM\FieldType\DBDatetime; class SudoModeService implements SudoModeServiceInterface { use Configurable; + use Extensible; /** * The lifetime that sudo mode authorization lasts for, in minutes. @@ -27,16 +29,18 @@ class SudoModeService implements SudoModeServiceInterface public function check(Session $session): bool { + $active = true; $lastActivated = $session->get(SudoModeService::SUDO_MODE_SESSION_KEY); - // Not activated at all if (!$lastActivated) { - return false; + // Not activated at all + $active = false; + } else { + // Activated within the last "lifetime" window + $nowTimestamp = DBDatetime::now()->getTimestamp(); + $active = $lastActivated > ($nowTimestamp - $this->getLifetime() * 60); } - - // Activated within the last "lifetime" window - $nowTimestamp = DBDatetime::now()->getTimestamp(); - - return $lastActivated > ($nowTimestamp - $this->getLifetime() * 60); + $this->extend('updateCheck', $active, $session); + return $active; } public function activate(Session $session): bool @@ -45,6 +49,11 @@ public function activate(Session $session): bool return true; } + public function deactivate(Session $session): void + { + $session->set(SudoModeService::SUDO_MODE_SESSION_KEY, null); + } + public function getLifetime(): int { return (int) static::config()->get('lifetime_minutes'); diff --git a/tests/php/Control/ControllerTest/TestController.php b/tests/php/Control/ControllerTest/TestController.php index 81537b14a25..1574541d79f 100644 --- a/tests/php/Control/ControllerTest/TestController.php +++ b/tests/php/Control/ControllerTest/TestController.php @@ -13,8 +13,9 @@ class TestController extends Controller implements TestOnly public function __construct() { parent::__construct(); - if (Controller::has_curr()) { - $this->setRequest(Controller::curr()->getRequest()); + $controller = Controller::curr(); + if ($controller) { + $this->setRequest($controller->getRequest()); } } diff --git a/tests/php/Control/DirectorTest/TestController.php b/tests/php/Control/DirectorTest/TestController.php index 344696ccf82..55ac1508ede 100644 --- a/tests/php/Control/DirectorTest/TestController.php +++ b/tests/php/Control/DirectorTest/TestController.php @@ -11,8 +11,9 @@ class TestController extends Controller implements TestOnly public function __construct() { parent::__construct(); - if (Controller::has_curr()) { - $this->setRequest(Controller::curr()->getRequest()); + $controller = Controller::curr(); + if ($controller) { + $this->setRequest($controller->getRequest()); } } diff --git a/tests/php/Control/Middleware/AllowedHostsMiddlewareTest.php b/tests/php/Control/Middleware/AllowedHostsMiddlewareTest.php new file mode 100644 index 00000000000..8da267c64af --- /dev/null +++ b/tests/php/Control/Middleware/AllowedHostsMiddlewareTest.php @@ -0,0 +1,119 @@ + [ + 'allowedHosts' => [], + 'isCli' => true, + 'allowed' => true, + ], + 'cli ignores config' => [ + 'allowedHosts' => ['example.org'], + 'isCli' => true, + 'allowed' => true, + ], + 'HTTP allow all' => [ + 'allowedHosts' => [], + 'isCli' => false, + 'allowed' => true, + ], + 'HTTP allow all explicit' => [ + 'allowedHosts' => ['*'], + 'isCli' => false, + 'allowed' => true, + ], + 'HTTP allow explicit host' => [ + 'allowedHosts' => ['www.example.com'], + 'isCli' => false, + 'allowed' => true, + ], + 'HTTP allow explicit host multiple values' => [ + 'allowedHosts' => ['example.com', 'example.org', 'ww.example.org', 'www.example.com'], + 'isCli' => false, + 'allowed' => true, + ], + 'HTTP allow explicit host string' => [ + 'allowedHosts' => 'example.com,example.org,ww.example.org,www.example.com', + 'isCli' => false, + 'allowed' => true, + ], + 'HTTP host mismatch (missing subdomain)' => [ + 'allowedHosts' => ['example.com'], + 'isCli' => false, + 'allowed' => false, + ], + 'HTTP host mismatch (different tld)' => [ + 'allowedHosts' => ['example.org'], + 'isCli' => false, + 'allowed' => false, + ], + 'HTTP host mismatch multiple' => [ + 'allowedHosts' => ['example.org', 'www.example.org', 'example.com'], + 'isCli' => false, + 'allowed' => false, + ], + 'HTTP host mismatch string' => [ + 'allowedHosts' => 'example.org,www.example.org,example.com', + 'isCli' => false, + 'allowed' => false, + ], + ]; + } + + /** + * @dataProvider provideProcess + */ + public function testProcess(string|array $allowedHosts, bool $isCli, bool $allowed): void + { + $reflectionEnvironment = new ReflectionClass(Environment::class); + $origIsCli = $reflectionEnvironment->getStaticPropertyValue('isCliOverride'); + $reflectionEnvironment->setStaticPropertyValue('isCliOverride', $isCli); + + try { + $middleware = new AllowedHostsMiddleware(); + $middleware->setAllowedHosts($allowedHosts); + $request = new HTTPRequest('GET', '/'); + $request->addHeader('host', 'www.example.com'); + $defaultResponse = new HTTPResponse(); + + $result = $middleware->process($request, function () use ($defaultResponse) { + return $defaultResponse; + }); + + if ($allowed) { + $this->assertSame(200, $result->getStatusCode()); + $this->assertSame($defaultResponse, $result); + } else { + $this->assertSame(400, $result->getStatusCode()); + $this->assertNotSame($defaultResponse, $result); + } + } finally { + $reflectionEnvironment->setStaticPropertyValue('isCliOverride', $origIsCli); + } + } + + public function testProcessInvalidConfig(): void + { + $middleware = new AllowedHostsMiddleware(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The wildcard "*" cannot be used in conjunction with actual hosts.'); + + $middleware->setAllowedHosts(['*', 'www.example.com']); + } +} diff --git a/tests/php/Control/RequestHandlingTest/TestController.php b/tests/php/Control/RequestHandlingTest/TestController.php index 9d2103860e8..9c323b8cd7c 100644 --- a/tests/php/Control/RequestHandlingTest/TestController.php +++ b/tests/php/Control/RequestHandlingTest/TestController.php @@ -42,8 +42,9 @@ public function __construct() { $this->failover = new ControllerFailover(); parent::__construct(); - if (Controller::has_curr()) { - $this->setRequest(Controller::curr()->getRequest()); + $controller = Controller::curr(); + if ($controller) { + $this->setRequest($controller->getRequest()); } } diff --git a/tests/php/Core/KernelTest.php b/tests/php/Core/KernelTest.php index afb5b37a7a7..2ad01bdca97 100644 --- a/tests/php/Core/KernelTest.php +++ b/tests/php/Core/KernelTest.php @@ -3,16 +3,20 @@ namespace SilverStripe\Core\Tests; use BadMethodCallException; +use Exception; +use Monolog\Logger; +use Psr\Log\LoggerInterface; +use ReflectionClass; use SilverStripe\Control\Director; +use SilverStripe\Control\Middleware\AllowedHostsMiddleware; use SilverStripe\Core\Config\Config; use SilverStripe\Core\Config\ConfigLoader; use SilverStripe\Core\CoreKernel; +use SilverStripe\Core\Environment; use SilverStripe\Core\Injector\Injector; use SilverStripe\Core\Injector\InjectorLoader; use SilverStripe\Core\Kernel; use SilverStripe\Dev\SapphireTest; -use SilverStripe\Core\Environment; -use ReflectionClass; use SilverStripe\ORM\DB; use ReflectionObject; use SilverStripe\Core\Tests\KernelTest\TestFlushable; @@ -126,4 +130,59 @@ public function testImplementorsAreCalled() // reset the kernel Flush flag $kernel->boot(); } + + public function provideAllowedHostsWarning(): array + { + $scenarios = [ + [ + 'config' => [], + 'isCli' => true, + 'shouldLog' => true, + ], + [ + 'config' => ['*'], + 'isCli' => true, + 'shouldLog' => false, + ], + [ + 'config' => ['www.example.com', 'example.org'], + 'isCli' => true, + 'shouldLog' => false, + ], + ]; + // Test both in CLI and non-CLI context + foreach ($scenarios as $name => $scenario) { + $scenario['isCli'] = false; + $scenarios[$name . ' (non-CLI)'] = $scenario; + } + return $scenarios; + } + + /** + * @dataProvider provideAllowedHostsWarning + */ + public function testAllowedHostsWarning(array $config, bool $isCli, bool $shouldLog): void + { + // Prepare mock to check if a warning is logged or not + $mockLogger = $this->getMockBuilder(Logger::class)->setConstructorArgs(['testLogger'])->getMock(); + $expectLog = $shouldLog ? $this->once() : $this->never(); + $mockLogger->expects($expectLog)->method('warning'); + Injector::inst()->registerService($mockLogger, LoggerInterface::class); + + // Set the config in our middleware + $middleware = Injector::inst()->get(AllowedHostsMiddleware::class, true); + $middleware->setAllowedHosts($config); + + $reflectionEnvironment = new ReflectionClass(Environment::class); + $origIsCli = $reflectionEnvironment->getStaticPropertyValue('isCliOverride'); + $reflectionEnvironment->setStaticPropertyValue('isCliOverride', $isCli); + + try { + $kernel = Injector::inst()->get(Kernel::class); + $kernel->nest(); // $kernel is no longer current kernel + $kernel->boot(); + } finally { + $reflectionEnvironment->setStaticPropertyValue('isCliOverride', $origIsCli); + } + } } diff --git a/tests/php/Forms/EmailFieldTest/TestController.php b/tests/php/Forms/EmailFieldTest/TestController.php index a0ae0ca3dc1..14bd0f488e8 100644 --- a/tests/php/Forms/EmailFieldTest/TestController.php +++ b/tests/php/Forms/EmailFieldTest/TestController.php @@ -17,8 +17,9 @@ class TestController extends Controller implements TestOnly public function __construct() { parent::__construct(); - if (Controller::has_curr()) { - $this->setRequest(Controller::curr()->getRequest()); + $controller = Controller::curr(); + if ($controller) { + $this->setRequest($controller->getRequest()); } } diff --git a/tests/php/Forms/FormFactoryTest/TestController.php b/tests/php/Forms/FormFactoryTest/TestController.php index 603ec56d32b..6ada9857bf1 100644 --- a/tests/php/Forms/FormFactoryTest/TestController.php +++ b/tests/php/Forms/FormFactoryTest/TestController.php @@ -15,8 +15,9 @@ class TestController extends Controller public function __construct() { parent::__construct(); - if (Controller::has_curr()) { - $this->setRequest(Controller::curr()->getRequest()); + $controller = Controller::curr(); + if ($controller) { + $this->setRequest($controller->getRequest()); } } diff --git a/tests/php/Forms/FormTest.php b/tests/php/Forms/FormTest.php index e66e48e789a..c9bd79f4fdc 100644 --- a/tests/php/Forms/FormTest.php +++ b/tests/php/Forms/FormTest.php @@ -35,6 +35,13 @@ use SilverStripe\View\SSViewer; use PHPUnit\Framework\Attributes\DataProvider; use SilverStripe\Forms\EmailField; +use SilverStripe\Forms\GridField\GridFieldDetailForm; +use SilverStripe\Forms\GridField\GridFieldDetailForm_ItemRequest; +use SilverStripe\Security\MemberAuthenticator\LostPasswordHandler; +use SilverStripe\Core\Injector\Injector; +use SilverStripe\Forms\SudoModePasswordField; +use SilverStripe\Security\SudoMode\SudoModeServiceInterface; +use SilverStripe\Forms\ReadonlyField; class FormTest extends FunctionalTest { @@ -1258,6 +1265,47 @@ public function testRestoreFromState() ); } + public static function provideRequireSudoMode(): array + { + return [ + 'sudo-protected' => [ + 'class' => GridFieldDetailForm_ItemRequest::class, + 'expected' => true, + ], + 'not-sudo-protected' => [ + 'class' => LostPasswordHandler::class, + 'expected' => false, + ], + ]; + } + + /** + * @dataProvider provideRequireSudoMode + */ + public function testRequireSudoMode(string $class, bool $expected): void + { + $request = Controller::curr()->getRequest(); + if ($class === GridFieldDetailForm_ItemRequest::class) { + $handler = new GridFieldDetailForm_ItemRequest(null, null, null, null, null); + } elseif ($class === LostPasswordHandler::class) { + $handler = new LostPasswordHandler(''); + } + $handler->setRequest($request); + $form = new Form($handler, 'MyForm', new FieldList(new TextField('MyTextField'))); + $form->requireSudoMode(); + $fieldList = $form->Fields(); + $last = $fieldList->last(); + $this->assertSame('MyTextField', $last->getName()); + if ($expected) { + $this->assertSame(2, $fieldList->count()); + $this->assertSame(SudoModePasswordField::class, get_class($fieldList->first())); + $this->assertSame(ReadonlyField::class, get_class($last)); + } else { + $this->assertSame(1, $fieldList->count()); + $this->assertSame(TextField::class, get_class($last)); + } + } + protected function getStubForm() { return new Form( diff --git a/tests/php/Forms/FormTest/ControllerWithSpecialSubmittedValueFields.php b/tests/php/Forms/FormTest/ControllerWithSpecialSubmittedValueFields.php index d16d59a8034..ca06198cd1b 100644 --- a/tests/php/Forms/FormTest/ControllerWithSpecialSubmittedValueFields.php +++ b/tests/php/Forms/FormTest/ControllerWithSpecialSubmittedValueFields.php @@ -23,8 +23,9 @@ class ControllerWithSpecialSubmittedValueFields extends Controller implements Te public function __construct() { parent::__construct(); - if (Controller::has_curr()) { - $this->setRequest(Controller::curr()->getRequest()); + $controller = Controller::curr(); + if ($controller) { + $this->setRequest($controller->getRequest()); } } diff --git a/tests/php/Forms/FormTest/TestController.php b/tests/php/Forms/FormTest/TestController.php index 1aac455062d..9e2f87a1b6d 100644 --- a/tests/php/Forms/FormTest/TestController.php +++ b/tests/php/Forms/FormTest/TestController.php @@ -22,8 +22,9 @@ class TestController extends Controller implements TestOnly public function __construct() { parent::__construct(); - if (Controller::has_curr()) { - $this->setRequest(Controller::curr()->getRequest()); + $controller = Controller::curr(); + if ($controller) { + $this->setRequest($controller->getRequest()); } } diff --git a/tests/php/Forms/GridField/GridFieldAddExistingAutocompleterTest/TestController.php b/tests/php/Forms/GridField/GridFieldAddExistingAutocompleterTest/TestController.php index 4ac2620b8e3..bb062edf523 100644 --- a/tests/php/Forms/GridField/GridFieldAddExistingAutocompleterTest/TestController.php +++ b/tests/php/Forms/GridField/GridFieldAddExistingAutocompleterTest/TestController.php @@ -17,8 +17,9 @@ class TestController extends Controller implements TestOnly public function __construct() { parent::__construct(); - if (Controller::has_curr()) { - $this->setRequest(Controller::curr()->getRequest()); + $controller = Controller::curr(); + if ($controller) { + $this->setRequest($controller->getRequest()); } } diff --git a/tests/php/Forms/GridField/GridFieldDetailFormTest/TestController.php b/tests/php/Forms/GridField/GridFieldDetailFormTest/TestController.php index 431a5bdba43..453d31a9ee3 100644 --- a/tests/php/Forms/GridField/GridFieldDetailFormTest/TestController.php +++ b/tests/php/Forms/GridField/GridFieldDetailFormTest/TestController.php @@ -20,8 +20,9 @@ class TestController extends Controller implements TestOnly public function __construct() { parent::__construct(); - if (Controller::has_curr()) { - $this->setRequest(Controller::curr()->getRequest()); + $controller = Controller::curr(); + if ($controller) { + $this->setRequest($controller->getRequest()); } } diff --git a/tests/php/Forms/GridField/GridFieldFilterHeaderTest.php b/tests/php/Forms/GridField/GridFieldFilterHeaderTest.php index 40f76006b92..913ebdc46b3 100644 --- a/tests/php/Forms/GridField/GridFieldFilterHeaderTest.php +++ b/tests/php/Forms/GridField/GridFieldFilterHeaderTest.php @@ -15,6 +15,7 @@ use SilverStripe\Forms\GridField\GridFieldFilterHeader; use SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest\Cheerleader; use SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest\CheerleaderHat; +use SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest\ModelWithBadSearchableFields; use SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest\Mom; use SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest\NonDataObject; use SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest\Team; @@ -59,6 +60,7 @@ class GridFieldFilterHeaderTest extends SapphireTest Cheerleader::class, CheerleaderHat::class, Mom::class, + ModelWithBadSearchableFields::class, ]; protected function setUp(): void @@ -222,15 +224,16 @@ public function testCanFilterAnyColumns() Config::modify()->set(Team::class, 'searchable_fields', ['Name']); $this->assertTrue($filterHeader->canFilterAnyColumns($gridField)); - // test that you can filterBy if searchable_fields even if it is not a legit field - // this is because we're making a blind assumption it will be filterable later in a SearchContext - Config::modify()->set(Team::class, 'searchable_fields', ['WhatIsThis']); - $this->assertTrue($filterHeader->canFilterAnyColumns($gridField)); - // test that you cannot filter by non-db field when it falls back to summary_fields Config::modify()->remove(Team::class, 'searchable_fields'); Config::modify()->set(Team::class, 'summary_fields', ['MySummaryField']); $this->assertFalse($filterHeader->canFilterAnyColumns($gridField)); + + // test that you can filterBy even if searchableFields() includes a non-db field + // this is because we're making a blind assumption it will be filterable in a custom SearchContext + $gridField->setList(ModelWithBadSearchableFields::get()); + $gridField->setModelClass(ModelWithBadSearchableFields::class); + $this->assertTrue($filterHeader->canFilterAnyColumns($gridField)); } public function testCanFilterAnyColumnsNonDataObject() diff --git a/tests/php/Forms/GridField/GridFieldFilterHeaderTest.yml b/tests/php/Forms/GridField/GridFieldFilterHeaderTest.yml index 47eeeb76528..d837d6dbe37 100644 --- a/tests/php/Forms/GridField/GridFieldFilterHeaderTest.yml +++ b/tests/php/Forms/GridField/GridFieldFilterHeaderTest.yml @@ -74,3 +74,7 @@ SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest\TeamGroup: City: Melbourne Cheerleader: =>SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest\Cheerleader.cheerleader1 CheerleadersMom: =>SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest\Mom.mom1 + +SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest\ModelWithBadSearchableFields: + record1: + Name: Record 1 diff --git a/tests/php/Forms/GridField/GridFieldFilterHeaderTest/ModelWithBadSearchableFields.php b/tests/php/Forms/GridField/GridFieldFilterHeaderTest/ModelWithBadSearchableFields.php new file mode 100644 index 00000000000..7412f343b95 --- /dev/null +++ b/tests/php/Forms/GridField/GridFieldFilterHeaderTest/ModelWithBadSearchableFields.php @@ -0,0 +1,34 @@ + 'Varchar', + ]; + + private static $summary_fields = [ + 'Name' => 'Name', + ]; + + // Explicitly empty + private static $searchable_fields = []; + + public function searchableFields() + { + // Explicitly only include this custom field + return [ + 'WhatIsThis' => [ + 'field' => TextField::class, + 'title' => $this->fieldLabel('WhatIsThis'), + ], + ]; + } +} diff --git a/tests/php/Forms/GridField/GridField_URLHandlerTest/TestController.php b/tests/php/Forms/GridField/GridField_URLHandlerTest/TestController.php index 4cbdc184a42..456a72c1658 100644 --- a/tests/php/Forms/GridField/GridField_URLHandlerTest/TestController.php +++ b/tests/php/Forms/GridField/GridField_URLHandlerTest/TestController.php @@ -15,8 +15,9 @@ class TestController extends Controller implements TestOnly public function __construct() { parent::__construct(); - if (Controller::has_curr()) { - $this->setRequest(Controller::curr()->getRequest()); + $controller = Controller::curr(); + if ($controller) { + $this->setRequest($controller->getRequest()); } } diff --git a/tests/php/ORM/DataObjectTest.php b/tests/php/ORM/DataObjectTest.php index 5ce748cbf70..d83dd869001 100644 --- a/tests/php/ORM/DataObjectTest.php +++ b/tests/php/ORM/DataObjectTest.php @@ -27,6 +27,17 @@ use SilverStripe\Security\Member; use SilverStripe\Model\ModelData; use ReflectionMethod; +use SilverStripe\Forms\CheckboxField; +use SilverStripe\Forms\CompositeField; +use SilverStripe\Forms\DatalessField; +use SilverStripe\Forms\DropdownField; +use SilverStripe\Forms\HiddenField; +use SilverStripe\Forms\LiteralField; +use SilverStripe\Forms\NumericField; +use SilverStripe\Forms\TextareaField; +use SilverStripe\Forms\TextField; +use SilverStripe\ORM\FieldType\DBInt; +use SilverStripe\ORM\Filters\WithinRangeFilter; use stdClass; class DataObjectTest extends SapphireTest @@ -1419,6 +1430,241 @@ public function testSearchableFields() $this->assertEmpty($fields); } + public static function provideSearchableFieldsForWithinRangeFilter(): array + { + return [ + 'invalid field in general' => [ + 'config' => [ + 'NotARealField' => [ + 'filter' => WithinRangeFilter::class, + 'rangeFromDefault' => 'some value', + 'rangeToDefault' => 'another value', + ], + ], + 'exceptionMessage' => 'NotARealField is not a relation/field on ' . DataObjectTest\Team::class, + 'expected' => null, + ], + 'cannot filter by relation with WithinRangeFilter' => [ + 'config' => [ + 'Captain' => [ + 'filter' => WithinRangeFilter::class, + ], + ], + 'exceptionMessage' => "dataType must be set to a DBField class for 'Captain'", + 'expected' => null, + ], + 'missing default "from"' => [ + 'config' => [ + 'Title' => ['filter' => WithinRangeFilter::class], + ], + 'exceptionMessage' => "rangeFromDefault must be set for 'Title'", + 'expected' => null, + ], + 'missing default "to"' => [ + 'config' => [ + 'Title' => [ + 'filter' => WithinRangeFilter::class, + 'rangeFromDefault' => 'some value', + ], + ], + 'exceptionMessage' => "rangeToDefault must be set for 'Title'", + 'expected' => null, + ], + 'fully valid config' => [ + 'config' => [ + 'Title' => [ + 'filter' => WithinRangeFilter::class, + 'rangeFromDefault' => 'some value', + 'rangeToDefault' => 'another value', + ], + ], + 'exceptionMessage' => null, + 'expected' => [ + 'Title' => [ + 'filter' => WithinRangeFilter::class, + 'dataType' => DBVarchar::class, + 'rangeFromDefault' => 'some value', + 'rangeToDefault' => 'another value', + 'title' => 'Title', + ], + ], + ], + 'fully valid config inferred from datatype' => [ + 'config' => [ + 'Title' => [ + 'filter' => WithinRangeFilter::class, + 'dataType' => DBInt::class, + ], + ], + 'exceptionMessage' => null, + 'expected' => [ + 'Title' => [ + 'filter' => WithinRangeFilter::class, + 'dataType' => DBInt::class, + 'title' => 'Title', + 'rangeFromDefault' => DBInt::getMinValue(), + 'rangeToDefault' => DBInt::getMaxValue(), + ], + ], + ], + ]; + } + + #[DataProvider('provideSearchableFieldsForWithinRangeFilter')] + public function testSearchableFieldsForWithinRangeFilter(array $config, ?string $exceptionMessage, ?array $expected): void + { + DataObjectTest\Team::config()->set('searchable_fields', $config); + $team = $this->objFromFixture(DataObjectTest\Team::class, 'team1'); + + if ($exceptionMessage !== null) { + $this->expectException(LogicException::class); + $this->expectExceptionMessage($exceptionMessage); + } + + $fields = $team->searchableFields(); + if ($expected !== null) { + $this->assertSame($expected, $fields); + } + } + + public static function provideScaffoldSearchFields(): array + { + return [ + 'inferred from simple config' => [ + 'config' => [ + 'Title', + 'DatabaseField', + 'NumericField', + 'Captain.IsRetired' => [ + 'Title' => 'Captain is retired', + ], + ], + 'generalSearchFieldName' => '', + 'expected' => [ + 'Title' => TextField::class, + 'DatabaseField' => TextField::class, + 'NumericField' => NumericField::class, + 'Captain__IsRetired' => DropdownField::class, + ], + ], + 'inferred from simple config (with general field)' => [ + 'config' => [ + 'Title', + 'DatabaseField', + 'NumericField', + 'Captain.IsRetired' => [ + 'Title' => 'Captain is retired', + ], + ], + 'generalSearchFieldName' => 'gen', + 'expected' => [ + 'gen' => HiddenField::class, + 'Title' => TextField::class, + 'DatabaseField' => TextField::class, + 'NumericField' => NumericField::class, + 'Captain__IsRetired' => DropdownField::class, + ], + ], + 'field specified in config' => [ + 'config' => [ + 'Title' => [ + 'field' => DatalessField::class, + ], + ], + 'generalSearchFieldName' => 'gen', + 'expected' => [ + 'gen' => HiddenField::class, + 'Title' => DatalessField::class, + ], + ], + 'no searchable fields' => [ + 'config' => [], + 'generalSearchFieldName' => 'gen', + 'expected' => [], + ], + 'within range duplicates field' => [ + 'config' => [ + 'NumericField' => WithinRangeFilter::class, + ], + 'generalSearchFieldName' => '', + 'expected' => [ + 'NumericField_SearchFrom' => NumericField::class, + 'NumericField_SearchTo' => NumericField::class, + ], + ], + 'search against relation (with method implemented)' => [ + 'config' => [ + 'Captain', + ], + 'generalSearchFieldName' => '', + 'expected' => [ + 'Captain.ID' => DropdownField::class, + ], + ], + ]; + } + + #[DataProvider('provideScaffoldSearchFields')] + public function testScaffoldSearchFields(array $config, string $generalSearchFieldName, array $expected): void + { + DataObjectTest\Team::config()->set('searchable_fields', $config); + DataObjectTest\Team::config()->set('general_search_field_name', $generalSearchFieldName); + $team = $this->objFromFixture(DataObjectTest\Team::class, 'team1'); + + $fields = $team->scaffoldSearchFields(); + $fieldMap = []; + foreach ($fields as $field) { + if ($field instanceof CompositeField) { + foreach ($field->getChildren() as $childField) { + $fieldMap[$childField->getName()] = get_class($childField); + } + continue; + } + $fieldMap[$field->getName()] = get_class($field); + } + $this->assertSame($expected, $fieldMap); + } + + public function testScaffoldSearchFieldsWithArg(): void + { + $config = [ + 'Title', + 'DatabaseField' => [ + // This will get overridden by fieldClasses arg + 'field' => DatalessField::class, + ], + 'NumericField', + 'Captain.IsRetired' => [ + 'Title' => 'Captain is retired', + ], + ]; + $args = [ + 'fieldClasses' => [ + 'DatabaseField' => NumericField::class, + ], + 'restrictFields' => [ + 'DatabaseField', + 'Captain.IsRetired' + ], + ]; + $expected = [ + 'DatabaseField' => NumericField::class, + 'Captain__IsRetired' => DropdownField::class, + ]; + + + DataObjectTest\Team::config()->set('searchable_fields', $config); + DataObjectTest\Team::config()->set('general_search_field_name', ''); + $team = $this->objFromFixture(DataObjectTest\Team::class, 'team1'); + + $fields = $team->scaffoldSearchFields($args); + $fieldMap = []; + foreach ($fields as $field) { + $fieldMap[$field->getName()] = get_class($field); + } + $this->assertSame($expected, $fieldMap); + } + public function testCastingHelper() { $team = $this->objFromFixture(DataObjectTest\Team::class, 'team1'); diff --git a/tests/php/ORM/DataObjectTest/Player.php b/tests/php/ORM/DataObjectTest/Player.php index c780e92b6b5..17c3e4899a3 100644 --- a/tests/php/ORM/DataObjectTest/Player.php +++ b/tests/php/ORM/DataObjectTest/Player.php @@ -3,6 +3,8 @@ namespace SilverStripe\ORM\Tests\DataObjectTest; use SilverStripe\Dev\TestOnly; +use SilverStripe\Forms\DropdownField; +use SilverStripe\Forms\FormField; use SilverStripe\ORM\DataObject; use SilverStripe\ORM\DataObjectSchema; use SilverStripe\ORM\Tests\DataObjectTest; @@ -48,4 +50,12 @@ public function ReturnsNull() { return null; } + + public function scaffoldSearchField(): FormField + { + // This is a weird scenario, given you have to explicitly say the relation name here. + // This is just here to ensure we don't break this in a minor or patch. There's no + // reason not to break this in a major (or else improve it so the relation name is passed in) + return DropdownField::create('Captain.ID', null, Player::get()->map()); + } } diff --git a/tests/php/ORM/Search/BasicSearchContextTest.php b/tests/php/ORM/Search/BasicSearchContextTest.php index 9d0157de118..5e40d628cc6 100644 --- a/tests/php/ORM/Search/BasicSearchContextTest.php +++ b/tests/php/ORM/Search/BasicSearchContextTest.php @@ -15,6 +15,7 @@ use SilverStripe\ORM\Search\BasicSearchContext; use SilverStripe\Model\ArrayData; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\DataProviderExternal; class BasicSearchContextTest extends SapphireTest { @@ -22,6 +23,7 @@ class BasicSearchContextTest extends SapphireTest protected static $extra_dataobjects = [ SearchContextTest\GeneralSearch::class, + SearchContextTest\WithinRangeFilterModel::class, ]; private function getList(): ArrayList @@ -339,4 +341,24 @@ public function testSpecificFieldsCanBeSkipped() $this->assertNotEmpty($general1->ExcludeThisField); $this->assertCount(0, $results); } + + + #[DataProviderExternal(SearchContextTest::class, 'provideQueryWithinRangeFilter')] + public function testQueryWithinRangeFilter(array $params, array $expectedFixtureNames): void + { + $model = SearchContextTest\WithinRangeFilterModel::singleton(); + $context = $model->getDefaultSearchContext(); + $results = $context->getResults($params)->column('ID'); + + $found = []; + foreach ($expectedFixtureNames as $fixtureName) { + $id = $this->idFromFixture(SearchContextTest\WithinRangeFilterModel::class, $fixtureName); + if (in_array($id, $results)) { + $found[] = $fixtureName; + } + } + + $this->assertSame($expectedFixtureNames, $found); + $this->assertCount(count($expectedFixtureNames), $results, 'More results found than expected'); + } } diff --git a/tests/php/ORM/Search/BasicSearchContextTest.yml b/tests/php/ORM/Search/BasicSearchContextTest.yml index dfdd30f344f..0259f86c560 100644 --- a/tests/php/ORM/Search/BasicSearchContextTest.yml +++ b/tests/php/ORM/Search/BasicSearchContextTest.yml @@ -26,3 +26,41 @@ SilverStripe\ORM\Tests\Search\SearchContextTest\GeneralSearch: PartialMatchField: MatchNothing MatchAny1: MatchNothing MatchAny2: MatchNothing + +SilverStripe\ORM\Tests\Search\SearchContextTest\WithinRangeFilterModel: + lowrange: + DateOnly: '1912-05-03' + Datetime: '1912-05-03 01:23:45' + DatetimeWithDateField: '1912-05-03 01:23:45' + TimeOnly: '01:23:45' + IntRange: 13 + DecimalRange: 1.234 + FloatRange: 1.234 + PercentageRange: 0.05 + YearRange: 1912 + CurrencyRange: 1.23 + VarcharRangeWithConfig: 'c' + midrange: + DateOnly: '2005-04-06' + Datetime: '2005-04-06 12:34:56' + DatetimeWithDateField: '2005-04-06 12:34:56' + TimeOnly: '12:34:56' + IntRange: 500 + DecimalRange: 12.34 + FloatRange: 12.34 + PercentageRange: 0.53 + YearRange: 2005 + CurrencyRange: 12.34 + VarcharRangeWithConfig: 'l' + highrange: + DateOnly: '2123-03-07' + Datetime: '2123-03-07 23:45:47' + DatetimeWithDateField: '2123-03-07 23:45:47' + TimeOnly: '23:45:47' + IntRange: 1234 + DecimalRange: 123.4 + FloatRange: 123.4 + PercentageRange: 0.95 + YearRange: 2123 + CurrencyRange: 123.4 + VarcharRangeWithConfig: 'x' diff --git a/tests/php/ORM/Search/SearchContextTest.php b/tests/php/ORM/Search/SearchContextTest.php index ec475c3c299..b9ba52a149f 100644 --- a/tests/php/ORM/Search/SearchContextTest.php +++ b/tests/php/ORM/Search/SearchContextTest.php @@ -3,6 +3,7 @@ namespace SilverStripe\ORM\Tests\Search; use LogicException; +use PHPUnit\Framework\Attributes\DataProvider; use ReflectionMethod; use SilverStripe\Core\Config\Config; use SilverStripe\Dev\SapphireTest; @@ -22,7 +23,6 @@ class SearchContextTest extends SapphireTest { - protected static $fixture_file = 'SearchContextTest.yml'; protected static $extra_dataobjects = [ @@ -34,6 +34,7 @@ class SearchContextTest extends SapphireTest SearchContextTest\Deadline::class, SearchContextTest\Action::class, SearchContextTest\AllFilterTypes::class, + SearchContextTest\WithinRangeFilterModel::class, SearchContextTest\Customer::class, SearchContextTest\Address::class, SearchContextTest\Order::class, @@ -178,6 +179,279 @@ public function testRelationshipObjectsLinkedInSearch() ); } + public static function provideQueryWithinRangeFilter(): array + { + return [ + // DBDate + 'date mid range' => [ + 'params' => [ + 'DateOnly_SearchFrom' => '1990-01-01', + 'DateOnly_SearchTo' => '2100-12-24', + ], + 'expectedFixtureNames' => ['midrange'], + ], + 'date from only' => [ + 'params' => [ + 'DateOnly_SearchFrom' => '1990-01-01', + ], + 'expectedFixtureNames' => ['midrange', 'highrange'], + ], + 'date to only' => [ + 'params' => [ + 'DateOnly_SearchTo' => '2100-12-24', + ], + 'expectedFixtureNames' => ['lowrange', 'midrange'], + ], + // DBDatetime + 'datetime mid range' => [ + 'params' => [ + 'Datetime_SearchFrom' => '1990-01-01 12:23:15', + 'Datetime_SearchTo' => '2100-12-24 12:23:15', + ], + 'expectedFixtureNames' => ['midrange'], + ], + 'datetime from only' => [ + 'params' => [ + 'Datetime_SearchFrom' => '1990-01-01 12:23:15', + ], + 'expectedFixtureNames' => ['midrange', 'highrange'], + ], + 'datetime to only' => [ + 'params' => [ + 'Datetime_SearchTo' => '2100-12-24 12:23:15', + ], + 'expectedFixtureNames' => ['lowrange', 'midrange'], + ], + 'datetime mid range date only' => [ + 'params' => [ + 'DatetimeWithDateField_SearchFrom' => '1990-01-01', + 'DatetimeWithDateField_SearchTo' => '2100-12-24', + ], + 'expectedFixtureNames' => ['midrange'], + ], + 'datetime from only date only' => [ + 'params' => [ + 'DatetimeWithDateField_SearchFrom' => '1990-01-01', + ], + 'expectedFixtureNames' => ['midrange', 'highrange'], + ], + 'datetime to only date only' => [ + 'params' => [ + 'DatetimeWithDateField_SearchTo' => '2100-12-24', + ], + 'expectedFixtureNames' => ['lowrange', 'midrange'], + ], + 'datetime mid range date only exact day' => [ + 'params' => [ + // The from time should end up being 00:00:00 + 'DatetimeWithDateField_SearchFrom' => '2005-04-06', + // The to time should end up being 24:59:59 + 'DatetimeWithDateField_SearchTo' => '2005-04-06', + ], + 'expectedFixtureNames' => ['midrange'], + ], + // DBTime + 'time mid range' => [ + 'params' => [ + 'TimeOnly_SearchFrom' => '05:23:45', + 'TimeOnly_SearchTo' => '17:01:23', + ], + 'expectedFixtureNames' => ['midrange'], + ], + 'time from only' => [ + 'params' => [ + 'TimeOnly_SearchFrom' => '05:23:45', + ], + 'expectedFixtureNames' => ['midrange', 'highrange'], + ], + 'time to only' => [ + 'params' => [ + 'TimeOnly_SearchTo' => '17:01:23', + ], + 'expectedFixtureNames' => ['lowrange', 'midrange'], + ], + // DBInt + 'int mid range' => [ + 'params' => [ + 'IntRange_SearchFrom' => '53', + 'IntRange_SearchTo' => '623', + ], + 'expectedFixtureNames' => ['midrange'], + ], + 'int from only' => [ + 'params' => [ + 'IntRange_SearchFrom' => '53', + ], + 'expectedFixtureNames' => ['midrange', 'highrange'], + ], + 'int to only' => [ + 'params' => [ + 'IntRange_SearchTo' => '623', + ], + 'expectedFixtureNames' => ['lowrange', 'midrange'], + ], + // DBDecimal + 'decimal mid range' => [ + 'params' => [ + 'DecimalRange_SearchFrom' => '2.0', + 'DecimalRange_SearchTo' => '63.125', + ], + 'expectedFixtureNames' => ['midrange'], + ], + 'decimal from only' => [ + 'params' => [ + 'DecimalRange_SearchFrom' => '2.0', + ], + 'expectedFixtureNames' => ['midrange', 'highrange'], + ], + 'decimal to only' => [ + 'params' => [ + 'DecimalRange_SearchTo' => '63.125', + ], + 'expectedFixtureNames' => ['lowrange', 'midrange'], + ], + // DBFloat + 'float mid range' => [ + 'params' => [ + 'FloatRange_SearchFrom' => '2.0', + 'FloatRange_SearchTo' => '63.125', + ], + 'expectedFixtureNames' => ['midrange'], + ], + 'float from only' => [ + 'params' => [ + 'FloatRange_SearchFrom' => '2.0', + ], + 'expectedFixtureNames' => ['midrange', 'highrange'], + ], + 'float to only' => [ + 'params' => [ + 'FloatRange_SearchTo' => '63.125', + ], + 'expectedFixtureNames' => ['lowrange', 'midrange'], + ], + // DBPercentage + 'percentage mid range' => [ + 'params' => [ + 'PercentageRange_SearchFrom' => '0.12', + 'PercentageRange_SearchTo' => '0.75', + ], + 'expectedFixtureNames' => ['midrange'], + ], + 'percentage from only' => [ + 'params' => [ + 'PercentageRange_SearchFrom' => '0.12', + ], + 'expectedFixtureNames' => ['midrange', 'highrange'], + ], + 'percentage to only' => [ + 'params' => [ + 'PercentageRange_SearchTo' => '0.75', + ], + 'expectedFixtureNames' => ['lowrange', 'midrange'], + ], + // DBYear + 'year mid range' => [ + 'params' => [ + 'YearRange_SearchFrom' => '1990', + 'YearRange_SearchTo' => '2100', + ], + 'expectedFixtureNames' => ['midrange'], + ], + 'year from only' => [ + 'params' => [ + 'YearRange_SearchFrom' => '1990', + ], + 'expectedFixtureNames' => ['midrange', 'highrange'], + ], + 'year to only' => [ + 'params' => [ + 'YearRange_SearchTo' => '2100', + ], + 'expectedFixtureNames' => ['lowrange', 'midrange'], + ], + // DBCurrency + 'currency mid range' => [ + 'params' => [ + 'CurrencyRange_SearchFrom' => '2.0', + 'CurrencyRange_SearchTo' => '63.125', + ], + 'expectedFixtureNames' => ['midrange'], + ], + 'currency from only' => [ + 'params' => [ + 'CurrencyRange_SearchFrom' => '2.0', + ], + 'expectedFixtureNames' => ['midrange', 'highrange'], + ], + 'currency to only' => [ + 'params' => [ + 'CurrencyRange_SearchTo' => '63.125', + ], + 'expectedFixtureNames' => ['lowrange', 'midrange'], + ], + // Special match_any config + 'match_any mid range' => [ + 'params' => [ + 'MatchAnyRange_SearchFrom' => '2.0', + 'MatchAnyRange_SearchTo' => '63.125', + ], + 'expectedFixtureNames' => ['midrange'], + ], + 'match_any from only' => [ + 'params' => [ + 'MatchAnyRange_SearchFrom' => '2.0', + ], + 'expectedFixtureNames' => ['midrange', 'highrange'], + ], + 'match_any to only' => [ + 'params' => [ + 'MatchAnyRange_SearchTo' => '63.125', + ], + 'expectedFixtureNames' => ['lowrange', 'midrange'], + ], + // DBVarchar + 'varchar mid range' => [ + 'params' => [ + 'VarcharRangeWithConfig_SearchFrom' => 'e', + 'VarcharRangeWithConfig_SearchTo' => 's', + ], + 'expectedFixtureNames' => ['midrange'], + ], + 'varchar from only' => [ + 'params' => [ + 'VarcharRangeWithConfig_SearchFrom' => 'e', + ], + 'expectedFixtureNames' => ['midrange', 'highrange'], + ], + 'varchar to only' => [ + 'params' => [ + 'VarcharRangeWithConfig_SearchTo' => 's', + ], + 'expectedFixtureNames' => ['lowrange', 'midrange'], + ], + ]; + } + + #[DataProvider('provideQueryWithinRangeFilter')] + public function testQueryWithinRangeFilter(array $params, array $expectedFixtureNames): void + { + $model = SearchContextTest\WithinRangeFilterModel::singleton(); + $context = $model->getDefaultSearchContext(); + $results = $context->getResults($params)->column('ID'); + + $found = []; + foreach ($expectedFixtureNames as $fixtureName) { + $id = $this->idFromFixture(SearchContextTest\WithinRangeFilterModel::class, $fixtureName); + if (in_array($id, $results)) { + $found[] = $fixtureName; + } + } + + $this->assertSame($expectedFixtureNames, $found); + $this->assertCount(count($expectedFixtureNames), $results, 'More results found than expected'); + } + public function testCanGenerateQueryUsingAllFilterTypes() { $all = SearchContextTest\AllFilterTypes::singleton(); diff --git a/tests/php/ORM/Search/SearchContextTest.yml b/tests/php/ORM/Search/SearchContextTest.yml index ea297934b09..3d714c0f69f 100644 --- a/tests/php/ORM/Search/SearchContextTest.yml +++ b/tests/php/ORM/Search/SearchContextTest.yml @@ -71,6 +71,44 @@ SilverStripe\ORM\Tests\Search\SearchContextTest\AllFilterTypes: EndsWith: abcd-efgh-ijkl FulltextField: one two three +SilverStripe\ORM\Tests\Search\SearchContextTest\WithinRangeFilterModel: + lowrange: + DateOnly: '1912-05-03' + Datetime: '1912-05-03 01:23:45' + DatetimeWithDateField: '1912-05-03 01:23:45' + TimeOnly: '01:23:45' + IntRange: 13 + DecimalRange: 1.234 + FloatRange: 1.234 + PercentageRange: 0.05 + YearRange: 1912 + CurrencyRange: 1.23 + VarcharRangeWithConfig: 'c' + midrange: + DateOnly: '2005-04-06' + Datetime: '2005-04-06 12:34:56' + DatetimeWithDateField: '2005-04-06 12:34:56' + TimeOnly: '12:34:56' + IntRange: 500 + DecimalRange: 12.34 + FloatRange: 12.34 + PercentageRange: 0.53 + YearRange: 2005 + CurrencyRange: 12.34 + VarcharRangeWithConfig: 'l' + highrange: + DateOnly: '2123-03-07' + Datetime: '2123-03-07 23:45:47' + DatetimeWithDateField: '2123-03-07 23:45:47' + TimeOnly: '23:45:47' + IntRange: 1234 + DecimalRange: 123.4 + FloatRange: 123.4 + PercentageRange: 0.95 + YearRange: 2123 + CurrencyRange: 123.4 + VarcharRangeWithConfig: 'x' + SilverStripe\ORM\Tests\Search\SearchContextTest\Customer: customer1: FirstName: Bill diff --git a/tests/php/ORM/Search/SearchContextTest/AllFilterTypes.php b/tests/php/ORM/Search/SearchContextTest/AllFilterTypes.php index 0caad766a3d..5aa6ab11e98 100644 --- a/tests/php/ORM/Search/SearchContextTest/AllFilterTypes.php +++ b/tests/php/ORM/Search/SearchContextTest/AllFilterTypes.php @@ -5,6 +5,9 @@ use SilverStripe\Dev\TestOnly; use SilverStripe\ORM\DataObject; +/** + * Note this model intentionally omits WithinRangeFilter because that will be tested separately. + */ class AllFilterTypes extends DataObject implements TestOnly { private static $table_name = 'SearchContextTest_AllFilterTypes'; diff --git a/tests/php/ORM/Search/SearchContextTest/WithinRangeFilterModel.php b/tests/php/ORM/Search/SearchContextTest/WithinRangeFilterModel.php new file mode 100644 index 00000000000..b1835740b5d --- /dev/null +++ b/tests/php/ORM/Search/SearchContextTest/WithinRangeFilterModel.php @@ -0,0 +1,62 @@ + 'Date', + 'Datetime' => 'Datetime', + 'DatetimeWithDateField' => 'Datetime', + 'TimeOnly' => 'Time', + 'IntRange' => 'Int', + 'DecimalRange' => 'Decimal', + 'FloatRange' => 'Float', + 'PercentageRange' => 'Percentage', + 'YearRange' => 'Year', + 'CurrencyRange' => 'Currency', + // Other field types require additional configuration but can work + 'VarcharRangeWithConfig' => 'Varchar', + ]; + + private static $searchable_fields = [ + 'DateOnly' => WithinRangeFilter::class, + 'Datetime' => WithinRangeFilter::class, + 'DatetimeWithDateField' => [ + 'filter' => WithinRangeFilter::class, + 'field' => DateField::class, + ], + 'TimeOnly' => WithinRangeFilter::class, + 'IntRange' => WithinRangeFilter::class, + 'DecimalRange' => WithinRangeFilter::class, + 'FloatRange' => WithinRangeFilter::class, + 'PercentageRange' => WithinRangeFilter::class, + 'YearRange' => WithinRangeFilter::class, + 'CurrencyRange' => WithinRangeFilter::class, + // Special "match_any" config can also work with this filter + 'MatchAnyRange' => [ + 'filter' => WithinRangeFilter::class, + 'dataType' => DBDecimal::class, + 'match_any' => [ + 'DecimalRange', + 'FloatRange', + 'CurrencyRange', + ], + ], + // Note the addition of rangeFromDefault and rangeToDefault here + 'VarcharRangeWithConfig' => [ + 'filter' => WithinRangeFilter::class, + 'rangeFromDefault' => 'a', + 'rangeToDefault' => 'z', + ], + ]; +} diff --git a/tests/php/Security/SudoMode/SudoModeServiceTest.php b/tests/php/Security/SudoMode/SudoModeServiceTest.php index 0dca89f6023..cadd478bee9 100644 --- a/tests/php/Security/SudoMode/SudoModeServiceTest.php +++ b/tests/php/Security/SudoMode/SudoModeServiceTest.php @@ -61,7 +61,7 @@ public function testActivateAndCheckImmediately() $this->assertTrue($this->service->check($this->session)); } - public function testSudoModeActivatesOnLogin() + public function testSudoModeNotActiveOnLogin() { // Sometimes being logged in carries over from other tests $this->logOut(); @@ -73,9 +73,9 @@ public function testSudoModeActivatesOnLogin() // Sudo mode should not be enabled automagically when nobody is logged in $this->assertFalse($service->check($session)); - // Ensure sudo mode is activated on login + // Ensure sudo mode is not automatically activated on login $this->logInWithPermission(); - $this->assertTrue($service->check($session)); + $this->assertFalse($service->check($session)); // Ensure sudo mode is not active after logging out $this->logOut();