Don't use session and FormSchema to manage server-side React validation responses

This commit is contained in:
Sam Minnee 2016-11-23 10:29:15 +13:00 committed by Damian Mooyman
parent f39c4d94f2
commit 6650561dac
19 changed files with 5985 additions and 5715 deletions

View File

@ -489,7 +489,7 @@ class DBFile extends DBComposite implements AssetContainer, Thumbnail
'Argument 1: Comma-separated list of valid extensions', 'Argument 1: Comma-separated list of valid extensions',
array('extensions' => wordwrap(implode(', ',$extensions))) array('extensions' => wordwrap(implode(', ',$extensions)))
); );
$result->error($message); $result->addError($message);
return false; return false;
} }

View File

@ -273,11 +273,7 @@ class FunctionalTest extends SapphireTest
$items = $this->cssParser()->getBySelector($selector); $items = $this->cssParser()->getBySelector($selector);
$actuals = array(); $actuals = array();
if ($items) { if($items) foreach($items as $item) $actuals[trim(preg_replace("/\s+/", " ", (string)$item))] = true;
foreach ($items as $item) {
$actuals[trim(preg_replace("/[ \n\r\t]+/", " ", $item. ''))] = true;
}
}
foreach($expectedMatches as $match) { foreach($expectedMatches as $match) {
$this->assertTrue( $this->assertTrue(

View File

@ -312,38 +312,34 @@ class Form extends RequestHandler
); );
/** /**
* Set up current form errors in session to * Take errors from a ValidationResult and populate the form with the appropriate message.
* the current form if appropriate.
* *
* @return $this * @param ValidationResult $result The erroneous ValidationResult. If none passed, this will be atken
* from the session
*/ */
public function setupFormErrors() public function setupFormErrors($result = null, $data = null) {
{ if(!$result) $result = Session::get("FormInfo.{$this->FormName()}.result");
$errorInfo = Session::get("FormInfo.{$this->FormName()}"); if(!$result) return;
if (isset($errorInfo['errors']) && is_array($errorInfo['errors'])) { foreach($result->fieldErrors() as $fieldName => $fieldError) {
foreach ($errorInfo['errors'] as $error) { $field = $this->fields->dataFieldByName($fieldName);
$field = $this->fields->dataFieldByName($error['fieldName']); $field->setError($fieldError['message'], $fieldError['messageType']);
}
if (!$field) { //don't escape the HTML as it should have been escaped when adding it to the validation result
$errorInfo['message'] = $error['message']; $this->setMessage($result->overallMessage(), $result->valid() ? 'good' : 'bad', false);
$errorInfo['type'] = $error['messageType'];
} else {
$field->setError($error['message'], $error['messageType']);
}
}
// load data in from previous submission upon error // load data in from previous submission upon error
if (isset($errorInfo['data'])) { if(!$data) $data = Session::get("FormInfo.{$this->FormName()}.data");
$this->loadDataFrom($errorInfo['data']); if($data) $this->loadDataFrom($data);
}
} }
if (isset($errorInfo['message']) && isset($errorInfo['type'])) { /**
$this->setMessage($errorInfo['message'], $errorInfo['type']); * Save information to the session to be picked up by {@link setUpFormErrors()}
} */
public function saveFormErrorsToSession($result = null, $data = null) {
return $this; Session::set("FormInfo.{$this->FormName()}.result", $result);
Session::set("FormInfo.{$this->FormName()}.data", $data);
} }
/** /**
@ -485,9 +481,28 @@ class Form extends RequestHandler
} }
}*/ }*/
// Validate the form // Action handlers may throw ValidationExceptions.
if (!$this->validate()) { try {
return $this->getValidationErrorResponse(); // Or we can use the Valiator attached to the form
$result = $this->validationResult();
if(!$result->valid()) {
return $this->getValidationErrorResponse($result);
}
// First, try a handler method on the controller (has been checked for allowed_actions above already)
if($this->controller->hasMethod($funcName)) {
return $this->controller->$funcName($vars, $this, $request);
// Otherwise, try a handler method on the form object.
} elseif($this->hasMethod($funcName)) {
return $this->$funcName($vars, $this, $request);
} elseif($field = $this->checkFieldsForAction($this->Fields(), $funcName)) {
return $field->$funcName($vars, $this, $request);
}
} catch(ValidationException $e) {
// The ValdiationResult contains all the relevant metadata
$result = $e->getResult();
return $this->getValidationErrorResponse($result);
} }
// First, try a handler method on the controller (has been checked for allowed_actions above already) // First, try a handler method on the controller (has been checked for allowed_actions above already)
@ -562,12 +577,12 @@ class Form extends RequestHandler
* Behaviour can be influenced by setting {@link $redirectToFormOnValidationError}, * Behaviour can be influenced by setting {@link $redirectToFormOnValidationError},
* and can be overruled by setting {@link $validationResponseCallback}. * and can be overruled by setting {@link $validationResponseCallback}.
* *
* @param ValidationResult $result
* @return HTTPResponse|string * @return HTTPResponse|string
*/ */
protected function getValidationErrorResponse() protected function getValidationErrorResponse(ValidationResult $result) {
{
$callback = $this->getValidationResponseCallback(); $callback = $this->getValidationResponseCallback();
if ($callback && $callbackResponse = $callback()) { if($callback && $callbackResponse = $callback($result)) {
return $callbackResponse; return $callbackResponse;
} }
@ -578,17 +593,23 @@ class Form extends RequestHandler
$acceptType = $request->getHeader('Accept'); $acceptType = $request->getHeader('Accept');
if (strpos($acceptType, 'application/json') !== false) { if (strpos($acceptType, 'application/json') !== false) {
// Send validation errors back as JSON with a flag at the start // Send validation errors back as JSON with a flag at the start
$response = new HTTPResponse(Convert::array2json($this->validator->getErrors())); $response = new HTTPResponse(Convert::array2json($result->getErrorMetaData()));
$response->addHeader('Content-Type', 'application/json'); $response->addHeader('Content-Type', 'application/json');
} else { } else {
$this->setupFormErrors(); $this->setupFormErrors($result, $this->getData());
// Send the newly rendered form tag as HTML // Send the newly rendered form tag as HTML
$response = new HTTPResponse($this->forTemplate()); $response = new HTTPResponse($this->forTemplate());
$response->addHeader('Content-Type', 'text/html'); $response->addHeader('Content-Type', 'text/html');
} }
return $response; return $response;
} else { } else {
// Save the relevant information in the session
$this->saveFormErrorsToSession($result, $this->getData());
// Redirect back to the form
if($this->getRedirectToFormOnValidationError()) { if($this->getRedirectToFormOnValidationError()) {
if($pageURL = $request->getHeader('Referer')) { if($pageURL = $request->getHeader('Referer')) {
if(Director::is_site_url($pageURL)) { if(Director::is_site_url($pageURL)) {
@ -684,18 +705,13 @@ class Form extends RequestHandler
/** /**
* Add a plain text error message to a field on this form. It will be saved into the session * Add a plain text error message to a field on this form. It will be saved into the session
* and used the next time this form is displayed. * and used the next time this form is displayed.
* @param string $fieldName *
* @param string $message * @deprecated 3.2
* @param string $messageType
* @param bool $escapeHtml
*/ */
public function addErrorMessage($fieldName, $message, $messageType, $escapeHtml = true) public function addErrorMessage($fieldName, $message, $messageType) {
{ Deprecation::notice('3.2', 'Throw a ValidationException instead.');
Session::add_to_array("FormInfo.{$this->FormName()}.errors", array(
'fieldName' => $fieldName, $this->getSessionValidationResult()->addFieldError($fieldName, $message, $messageType);
'message' => $escapeHtml ? Convert::raw2xml($message) : $message,
'messageType' => $messageType,
));
} }
/** /**
@ -1349,38 +1365,17 @@ class Form extends RequestHandler
* *
* @return string * @return string
*/ */
public function Message() public function Message() {
{
$this->getMessageFromSession();
return $this->message; return $this->message;
} }
/** /**
* @return string * @return string
*/ */
public function MessageType() public function MessageType() {
{
$this->getMessageFromSession();
return $this->messageType; return $this->messageType;
} }
/**
* @return string
*/
protected function getMessageFromSession()
{
if ($this->message || $this->messageType) {
return $this->message;
} else {
$this->message = Session::get("FormInfo.{$this->FormName()}.formError.message");
$this->messageType = Session::get("FormInfo.{$this->FormName()}.formError.type");
return $this->message;
}
}
/** /**
* Set a status message for the form. * Set a status message for the form.
* *
@ -1409,34 +1404,67 @@ class Form extends RequestHandler
*/ */
public function sessionMessage($message, $type, $escapeHtml = true) public function sessionMessage($message, $type, $escapeHtml = true)
{ {
Session::set( // Benign message
"FormInfo.{$this->FormName()}.formError.message", if($type == "good") {
$escapeHtml ? Convert::raw2xml($message) : $message $this->getSessionValidationResult()->addMessage($message, $type, null, $escapeHtml);
// Bad message causing a validation error
} else {
$this->getSessionValidationResult()->addError($message, $type, null, $escapeHtml
); );
Session::set("FormInfo.{$this->FormName()}.formError.type", $type); }
} }
public static function messageForForm($formName, $message, $type, $escapeHtml = true) /**
{ * @deprecated 3.1
Session::set( */
"FormInfo.{$formName}.formError.message", public static function messageForForm($formName, $message, $type) {
$escapeHtml ? Convert::raw2xml($message) : $message Deprecation::notice('3.1', 'Create an instance of the form you wish to attach a message to.');
); }
Session::set("FormInfo.{$formName}.formError.type", $type);
/**
* Returns the ValidationResult stored in the session.
* You can use this to modify messages without throwing a ValidationException.
* If a ValidationResult doesn't yet exist, a new one will be created
*
* @return ValidationResult The ValidationResult object stored in the session
*/
public function getSessionValidationResult() {
$result = Session::get("FormInfo.{$this->FormName()}.result");
if(!$result || !($result instanceof ValidationResult)) {
$result = new ValidationResult;
Session::set("FormInfo.{$this->FormName()}.result", $result);
}
return $result;
}
/**
* Sets the ValidationResult in the session to be used with the next view of this form.
* @param ValidationResult $result The result to save
* @param boolean $combineWithExisting If true, then this will be added to the existing result.
*/
public function setSessionValidationResult(ValidationResult $result, $combineWithExisting = false) {
if($combineWithExisting) {
$existingResult = $this->getSessionValidationResult();
$existingResult->combineAnd($result);
} else {
Session::set("FormInfo.{$this->FormName()}.result", $result);
}
} }
public function clearMessage() public function clearMessage()
{ {
$this->message = null; $this->message = null;
Session::clear("FormInfo.{$this->FormName()}.errors"); Session::clear("FormInfo.{$this->FormName()}.result");
Session::clear("FormInfo.{$this->FormName()}.formError");
Session::clear("FormInfo.{$this->FormName()}.data"); Session::clear("FormInfo.{$this->FormName()}.data");
} }
public function resetValidation() public function resetValidation() {
{
Session::clear("FormInfo.{$this->FormName()}.errors");
Session::clear("FormInfo.{$this->FormName()}.data"); Session::clear("FormInfo.{$this->FormName()}.data");
Session::clear("FormInfo.{$this->FormName()}.result");
} }
/** /**
@ -1464,52 +1492,60 @@ class Form extends RequestHandler
/** /**
* Processing that occurs before a form is executed. * Processing that occurs before a form is executed.
* *
* This includes form validation, if it fails, we throw a ValidationException
*
* This includes form validation, if it fails, we redirect back * This includes form validation, if it fails, we redirect back
* to the form with appropriate error messages. * to the form with appropriate error messages.
* Always return true if the current form action is exempt from validation * Always return true if the current form action is exempt from validation
* *
* Triggered through {@link httpSubmission()}. * Triggered through {@link httpSubmission()}.
* *
*
* Note that CSRF protection takes place in {@link httpSubmission()}, * Note that CSRF protection takes place in {@link httpSubmission()},
* if it fails the form data will never reach this method. * if it fails the form data will never reach this method.
* *
* @return boolean * @return boolean
*/ */
public function validate() public function validate(){
{ $result = $this->validationResult();
// Valid
if($result->valid()) {
return true;
// Invalid
} else {
$this->saveFormErrorsToSession($result, $this->getData());
return false;
}
}
/**
* Experimental method - return a ValidationResult for the validator
* @return [type] [description]
*/
private function validationResult() {
// Start with a "valid" validation result
$result = ValidationResult::create();
// Opportunity to invalidate via validator
$action = $this->buttonClicked(); $action = $this->buttonClicked();
if($action && $this->actionIsValidationExempt($action)) { if($action && $this->actionIsValidationExempt($action)) {
return true; return $result;
} }
if($this->validator){ if($this->validator){
$errors = $this->validator->validate(); $errors = $this->validator->validate();
// Convert the old-style Validator result into a ValidationResult
if($errors){ if($errors){
// Load errors into session and post back foreach($errors as $error) {
$data = $this->getData(); $result->addFieldError($error['fieldName'], $error['message'], $error['messageType']);
// Encode validation messages as XML before saving into session state
// As per Form::addErrorMessage()
$errors = array_map(function ($error) {
// Encode message as XML by default
if ($error['message'] instanceof DBField) {
$error['message'] = $error['message']->forTemplate();
;
} else {
$error['message'] = Convert::raw2xml($error['message']);
} }
return $error;
}, $errors);
Session::set("FormInfo.{$this->FormName()}.errors", $errors);
Session::set("FormInfo.{$this->FormName()}.data", $data);
return false;
} }
} }
return true; return $result;
} }
const MERGE_DEFAULT = 0; const MERGE_DEFAULT = 0;

View File

@ -169,18 +169,14 @@ class GridFieldDeleteAction implements GridField_ColumnProvider, GridField_Actio
if($actionName == 'deleterecord') { if($actionName == 'deleterecord') {
if(!$item->canDelete()) { if(!$item->canDelete()) {
throw new ValidationException( throw new ValidationException(
_t('GridFieldAction_Delete.DeletePermissionsFailure', "No delete permissions"), _t('GridFieldAction_Delete.DeletePermissionsFailure',"No delete permissions"));
0
);
} }
$item->delete(); $item->delete();
} else { } else {
if(!$item->canEdit()) { if(!$item->canEdit()) {
throw new ValidationException( throw new ValidationException(
_t('GridFieldAction_Delete.EditPermissionsFailure', "No permission to unlink record"), _t('GridFieldAction_Delete.EditPermissionsFailure',"No permission to unlink record"));
0
);
} }
$gridField->getList()->remove($item); $gridField->getList()->remove($item);

View File

@ -524,9 +524,7 @@ class GridFieldDetailForm_ItemRequest extends RequestHandler
try { try {
if (!$this->record->canDelete()) { if (!$this->record->canDelete()) {
throw new ValidationException( throw new ValidationException(
_t('GridFieldDetailForm.DeletePermissionsFailure', "No delete permissions"), _t('GridFieldDetailForm.DeletePermissionsFailure',"No delete permissions"));
0
);
} }
$this->record->delete(); $this->record->delete();

View File

@ -112,13 +112,14 @@ class Hierarchy extends DataExtension
while($node) { while($node) {
if ($node->ParentID==$this->owner->ID) { if ($node->ParentID==$this->owner->ID) {
// Hierarchy is looping. // Hierarchy is looping.
$validationResult->error( $validationResult->addError(
_t( _t(
'Hierarchy.InfiniteLoopNotAllowed', 'Hierarchy.InfiniteLoopNotAllowed',
'Infinite loop found within the "{type}" hierarchy. Please change the parent to resolve this', 'Infinite loop found within the "{type}" hierarchy. Please change the parent to resolve this',
'First argument is the class that makes up the hierarchy.', 'First argument is the class that makes up the hierarchy.',
array('type' => $this->owner->class) array('type' => $this->owner->class)
), ),
'bad',
'INFINITE_LOOP' 'INFINITE_LOOP'
); );
break; break;

View File

@ -30,25 +30,45 @@ class ValidationException extends Exception
* the error code number. * the error code number.
* @param integer $code The error code number, if not given in the second parameter * @param integer $code The error code number, if not given in the second parameter
*/ */
public function __construct($result = null, $message = null, $code = 0) public function __construct($result = null, $code = 0, $dummy = null) {
{ $exceptionMessage = null;
// Check arguments // Backwards compatibiliy failover. The 2nd argument used to be $message, and $code the 3rd.
if (!($result instanceof ValidationResult)) { // For callers using that, we ditch the message
// Shift parameters if no ValidationResult is given if(!is_numeric($code)) {
$code = $message; $exceptionMessage = $code;
$message = $result; if($dummy) $code = $dummy;
}
// Infer ValidationResult from parameters if($result instanceof ValidationResult) {
$result = new ValidationResult(false, $message); $this->result = $result;
} elseif (empty($message)) {
// Infer message if not given } else if(is_string($result)) {
$message = $result->message(); $this->result = ValidationResult::create()->addError($result);
} else if(!$result) {
$this->result = ValidationResult::create()->addError(_t("ValdiationExcetpion.DEFAULT_ERROR", "Validation error"));
} else {
throw new InvalidArgumentException(
"ValidationExceptions must be passed a ValdiationResult, a string, or nothing at all");
} }
// Construct // Construct
$this->result = $result; parent::__construct($exceptionMessage ? $exceptionMessage : $this->result->message(), $code);
parent::__construct($message, $code); }
/**
* Create a ValidationException with a message for a single field-specific error message.
*
* @param string $field The field name
* @param string $message The error message
* @return ValidationException
*/
static function create_for_field($field, $message) {
$result = new ValidationResult;
$result->addFieldError($field, $message);
return new ValidationException($result);
} }
/** /**

View File

@ -13,7 +13,7 @@ class ValidationResult extends Object
/** /**
* @var bool - is the result valid or not * @var bool - is the result valid or not
*/ */
protected $isValid; protected $isValid = true;
/** /**
@ -24,11 +24,17 @@ class ValidationResult extends Object
/** /**
* Create a new ValidationResult. * Create a new ValidationResult.
* By default, it is a successful result. Call $this->error() to record errors. * By default, it is a successful result. Call $this->error() to record errors.
* @param bool $valid *
* @param string|null $message * @param void $valid @deprecated
* @param void $message @deprecated
*/ */
public function __construct($valid = true, $message = null) public function __construct($valid = null, $message = null) {
{ if ($message !== null) {
Deprecation::notice('3.2', '$message parameter is deprecated please use addMessage or addError instead', false);
$this->addError($message);
}
if ($valid !== null) {
Deprecation::notice('3.2', '$valid parameter is deprecated please addError to mark the result as invalid', false);
$this->isValid = $valid; $this->isValid = $valid;
if ($message) { if ($message) {
$this->errorList[] = $message; $this->errorList[] = $message;
@ -37,25 +43,117 @@ class ValidationResult extends Object
} }
/** /**
* Record an error against this validation result, * Return the full error meta-data, suitable for combining with another ValidationResult.
* @param string $message The validation error message
* @param string $code An optional error code string, that can be accessed with {@link $this->codeList()}.
* @return $this
*/ */
public function error($message, $code = null) function getErrorMetaData() {
{ return $this->errorList;
}
/**
* Record a
* against this validation result.
*
* It's better to use addError, addFeildError, addMessage, or addFieldMessage instead.
*
* @param string $message The message string.
* @param string $code A codename for this error. Only one message per codename will be added.
* This can be usedful for ensuring no duplicate messages
* @param string $fieldName The field to link the message to. If omitted; a form-wide message is assumed.
* @param string $messageType The type of message: e.g. "bad", "warning", "good", or "required". Passed as a CSS
* class to the form, so other values can be used if desired.
* @param bool $escapeHtml Automatically sanitize the message. Set to FALSE if the message contains HTML.
* In that case, you might want to use {@link Convert::raw2xml()} to escape any
* user supplied data in the message.
*
* @deprecated 3.2
*/
public function error($message, $code = null, $fieldName = null, $messageType = "bad", $escapeHtml = true) {
Deprecation::notice('3.2', 'Use addError or addFieldError instead.');
return $this->addFieldError($fieldName, $message, $messageType, $code, $escapeHtml);
}
/**
* Record an error against this validation result,
*
* @param string $message The message string.
* @param string $messageType The type of message: e.g. "bad", "warning", "good", or "required". Passed as a CSS
* class to the form, so other values can be used if desired.
* @param string $code A codename for this error. Only one message per codename will be added.
* This can be usedful for ensuring no duplicate messages
* @param bool $escapeHtml Automatically sanitize the message. Set to FALSE if the message contains HTML.
* In that case, you might want to use {@link Convert::raw2xml()} to escape any
* user supplied data in the message.
*/
public function addError($message, $messageType = "bad", $code = null, $escapeHtml = true) {
return $this->addFieldError(null, $message, $messageType, $code, $escapeHtml);
}
/**
* Record an error against this validation result,
*
* @param string $fieldName The field to link the message to. If omitted; a form-wide message is assumed.
* @param string $message The message string.
* @param string $messageType The type of message: e.g. "bad", "warning", "good", or "required". Passed as a CSS
* class to the form, so other values can be used if desired.
* @param string $code A codename for this error. Only one message per codename will be added.
* This can be usedful for ensuring no duplicate messages
* @param bool $escapeHtml Automatically sanitize the message. Set to FALSE if the message contains HTML.
* In that case, you might want to use {@link Convert::raw2xml()} to escape any
* user supplied data in the message.
*/
public function addFieldError($fieldName = null, $message, $messageType = "bad", $code = null, $escapeHtml = true) {
$this->isValid = false; $this->isValid = false;
return $this->addFieldMessage($fieldName, $message, $messageType, $code, $escapeHtml);
}
/**
* Add a message to this ValidationResult without necessarily marking it as an error
*
* @param string $message The message string.
* @param string $messageType The type of message: e.g. "bad", "warning", "good", or "required". Passed as a CSS
* class to the form, so other values can be used if desired.
* @param string $code A codename for this error. Only one message per codename will be added.
* This can be usedful for ensuring no duplicate messages
* @param bool $escapeHtml Automatically sanitize the message. Set to FALSE if the message contains HTML.
* In that case, you might want to use {@link Convert::raw2xml()} to escape any
* user supplied data in the message.
*/
public function addMessage($message, $messageType = "bad", $code = null, $escapeHtml = true) {
return $this->addFieldMessage(null, $message, $messageType, $code, $escapeHtml);
}
/**
* Add a message to this ValidationResult without necessarily marking it as an error
*
* @param string $fieldName The field to link the message to. If omitted; a form-wide message is assumed.
* @param string $message The message string.
* @param string $messageType The type of message: e.g. "bad", "warning", "good", or "required". Passed as a CSS
* class to the form, so other values can be used if desired.
* @param string $code A codename for this error. Only one message per codename will be added.
* This can be usedful for ensuring no duplicate messages
* @param bool $escapeHtml Automatically sanitize the message. Set to FALSE if the message contains HTML.
* In that case, you might want to use {@link Convert::raw2xml()} to escape any
* user supplied data in the message.
*/
public function addFieldMessage($fieldName, $message, $messageType = "bad", $code = null, $escapeHtml = true) {
$metadata = array(
'message' => $escapeHtml ? Convert::raw2xml($message) : $message,
'fieldName' => $fieldName,
'messageType' => $messageType,
);
if($code) { if($code) {
if(!is_numeric($code)) { if(!is_numeric($code)) {
$this->errorList[$code] = $message; $this->errorList[$code] = $metadata;
} else { } else {
user_error("ValidationResult::error() - Don't use a numeric code '$code'. Use a string." throw new InvalidArgumentException(
. "I'm going to ignore it.", E_USER_WARNING); "ValidationResult::error() - Don't use a numeric code '$code'. Use a string.");
$this->errorList[$code] = $message;
} }
} else { } else {
$this->errorList[] = $message; $this->errorList[] = $metadata;
} }
return $this; return $this;
@ -76,7 +174,29 @@ class ValidationResult extends Object
*/ */
public function messageList() public function messageList()
{ {
return $this->errorList; $list = array();
foreach($this->errorList as $key => $item) {
if(is_numeric($key)) $list[] = $item['message'];
else $list[$key] = $item['message'];
}
return $list;
}
/**
* Get the field-specific messages as a map.
* Keys will be field names, and values will be a 2 element map with keys 'messsage', and 'messageType'
*/
public function fieldErrors() {
$output = array();
foreach($this->errorList as $key => $item) {
if($item['fieldName']) {
$output[$item['fieldName']] = array(
'message' => $item['message'],
'messageType' => $item['messageType']
);
}
}
return $output;
} }
/** /**
@ -100,7 +220,18 @@ class ValidationResult extends Object
*/ */
public function message() public function message()
{ {
return implode("; ", $this->errorList); return implode("; ", $this->messageList());
}
/**
* The the error message that's not related to a field as a string
*/
public function overallMessage() {
$messages = array();
foreach($this->errorList as $item) {
if(!$item['fieldName']) $messages[] = $item['message'];
}
return implode("; ", $messages);
} }
/** /**
@ -109,7 +240,7 @@ class ValidationResult extends Object
*/ */
public function starredList() public function starredList()
{ {
return " * " . implode("\n * ", $this->errorList); return " * " . implode("\n * ", $this->messageList());
} }
/** /**
@ -123,8 +254,7 @@ class ValidationResult extends Object
public function combineAnd(ValidationResult $other) public function combineAnd(ValidationResult $other)
{ {
$this->isValid = $this->isValid && $other->valid(); $this->isValid = $this->isValid && $other->valid();
$this->errorList = array_merge($this->errorList, $other->messageList()); $this->errorList = array_merge($this->errorList, $other->getErrorMetaData());
return $this; return $this;
} }
} }

View File

@ -428,7 +428,7 @@ class Group extends DataObject
->column('Code'); ->column('Code');
$privilegedCodes = Config::inst()->get('SilverStripe\\Security\\Permission', 'privileged_permissions'); $privilegedCodes = Config::inst()->get('SilverStripe\\Security\\Permission', 'privileged_permissions');
if(array_intersect($inheritedCodes, $privilegedCodes)) { if(array_intersect($inheritedCodes, $privilegedCodes)) {
$result->error(sprintf( $result->addError(sprintf(
_t( _t(
'Group.HierarchyPermsError', 'Group.HierarchyPermsError',
'Can\'t assign parent group "%s" with privileged permissions (requires ADMIN access)' 'Can\'t assign parent group "%s" with privileged permissions (requires ADMIN access)'

View File

@ -329,13 +329,13 @@ class Member extends DataObject implements TemplateGlobalProvider
// Check a password is set on this member // Check a password is set on this member
if(empty($this->Password) && $this->exists()) { if(empty($this->Password) && $this->exists()) {
$result->error(_t('Member.NoPassword', 'There is no password on this member.')); $result->addError(_t('Member.NoPassword','There is no password on this member.'));
return $result; return $result;
} }
$e = PasswordEncryptor::create_for_algorithm($this->PasswordEncryption); $e = PasswordEncryptor::create_for_algorithm($this->PasswordEncryption);
if(!$e->check($this->Password, $password, $this->Salt, $this)) { if(!$e->check($this->Password, $password, $this->Salt, $this)) {
$result->error(_t( $result->addError(_t (
'Member.ERRORWRONGCRED', 'Member.ERRORWRONGCRED',
'The provided details don\'t seem to be correct. Please try again.' 'The provided details don\'t seem to be correct. Please try again.'
)); ));
@ -368,7 +368,7 @@ class Member extends DataObject implements TemplateGlobalProvider
$result = ValidationResult::create(); $result = ValidationResult::create();
if($this->isLockedOut()) { if($this->isLockedOut()) {
$result->error( $result->addError(
_t( _t(
'Member.ERRORLOCKEDOUT2', 'Member.ERRORLOCKEDOUT2',
'Your account has been temporarily disabled because of too many failed attempts at ' . 'Your account has been temporarily disabled because of too many failed attempts at ' .
@ -938,7 +938,7 @@ class Member extends DataObject implements TemplateGlobalProvider
$existingRecord = DataObject::get_one('SilverStripe\\Security\\Member', $filter); $existingRecord = DataObject::get_one('SilverStripe\\Security\\Member', $filter);
if($existingRecord) { if($existingRecord) {
throw new ValidationException(ValidationResult::create(false, _t( throw new ValidationException(ValidationResult::create()->adderror(_t(
'Member.ValidationIdentifierFailed', 'Member.ValidationIdentifierFailed',
'Can\'t overwrite existing member #{id} with identical identifier ({name} = {value}))', 'Can\'t overwrite existing member #{id} with identical identifier ({name} = {value}))',
'Values in brackets show "fieldname = value", usually denoting an existing email address', 'Values in brackets show "fieldname = value", usually denoting an existing email address',

View File

@ -83,17 +83,13 @@ class MemberAuthenticator extends Authenticator
$result = $member->checkPassword($data['Password']); $result = $member->checkPassword($data['Password']);
$success = $result->valid(); $success = $result->valid();
} else { } else {
$result = new ValidationResult(false, _t('Member.ERRORWRONGCRED')); $result = ValidationResult::create()->addError(_t('Member.ERRORWRONGCRED'));
} }
// Emit failure to member and form (if available) // Emit failure to member and form (if available)
if(!$success) { if(!$success) {
if ($member) { if($member) $member->registerFailedLogin();
$member->registerFailedLogin(); if($form) $form->setSessionValidationResult($result, true);
}
if ($form) {
$form->sessionMessage($result->message(), 'bad');
}
} else { } else {
if ($member) { if ($member) {
$member->registerSuccessfulLogin(); $member->registerSuccessfulLogin();

View File

@ -80,7 +80,7 @@ class PasswordValidator extends Object
if($this->minLength) { if($this->minLength) {
if(strlen($password) < $this->minLength) { if(strlen($password) < $this->minLength) {
$valid->error( $valid->addError(
sprintf( sprintf(
_t( _t(
'PasswordValidator.TOOSHORT', 'PasswordValidator.TOOSHORT',
@ -88,6 +88,7 @@ class PasswordValidator extends Object
), ),
$this->minLength $this->minLength
), ),
'bad',
'TOO_SHORT' 'TOO_SHORT'
); );
} }
@ -109,7 +110,7 @@ class PasswordValidator extends Object
} }
if($score < $this->minScore) { if($score < $this->minScore) {
$valid->error( $valid->addError(
sprintf( sprintf(
_t( _t(
'PasswordValidator.LOWCHARSTRENGTH', 'PasswordValidator.LOWCHARSTRENGTH',
@ -117,6 +118,7 @@ class PasswordValidator extends Object
), ),
implode(', ', $missedTests) implode(', ', $missedTests)
), ),
'bad',
'LOW_CHARACTER_STRENGTH' 'LOW_CHARACTER_STRENGTH'
); );
} }
@ -130,11 +132,12 @@ class PasswordValidator extends Object
/** @var MemberPassword $previousPassword */ /** @var MemberPassword $previousPassword */
foreach($previousPasswords as $previousPassword) { foreach($previousPasswords as $previousPassword) {
if($previousPassword->checkPassword($password)) { if($previousPassword->checkPassword($password)) {
$valid->error( $valid->addError(
_t( _t(
'PasswordValidator.PREVPASSWORD', 'PasswordValidator.PREVPASSWORD',
'You\'ve already used that password in the past, please choose a new password' 'You\'ve already used that password in the past, please choose a new password'
), ),
'bad',
'PREVIOUS_PASSWORD' 'PREVIOUS_PASSWORD'
); );
break; break;

View File

@ -33,7 +33,7 @@ class PermissionRoleCode extends DataObject
&& in_array($this->Code, $privilegedCodes) && in_array($this->Code, $privilegedCodes)
&& !Permission::check('ADMIN') && !Permission::check('ADMIN')
) { ) {
$result->error(sprintf( $result->addError(sprintf(
_t( _t(
'PermissionRoleCode.PermsError', 'PermissionRoleCode.PermsError',
'Can\'t assign code "%s" with privileged permissions (requires ADMIN access)' 'Can\'t assign code "%s" with privileged permissions (requires ADMIN access)'

View File

@ -436,6 +436,34 @@ class FormTest extends FunctionalTest {
); );
} }
public function testValidationException() {
$this->get('FormTest_Controller');
$response = $this->post(
'FormTest_Controller/Form',
array(
'Email' => 'test@test.com',
'SomeRequiredField' => 'test',
'action_triggerException' => 1,
)
);
$this->assertPartialMatchBySelector(
'#Form_Form_Email_Holder span.message',
array(
'Error on Email field'
),
'Formfield validation shows note on field if invalid'
);
$this->assertPartialMatchBySelector(
'#Form_Form_error',
array(
'Error at top of form'
),
'Required fields show a notification on field when left blank'
);
}
public function testGloballyDisabledSecurityTokenInheritsToNewForm() { public function testGloballyDisabledSecurityTokenInheritsToNewForm() {
SecurityToken::enable(); SecurityToken::enable();
@ -758,6 +786,7 @@ class FormTest extends FunctionalTest {
$form = $this->getStubForm(); $form = $this->getStubForm();
$form->getController()->handleRequest(new HTTPRequest('GET', '/'), DataModel::inst()); // stub out request $form->getController()->handleRequest(new HTTPRequest('GET', '/'), DataModel::inst()); // stub out request
$form->sessionMessage('<em>Escaped HTML</em>', 'good', true); $form->sessionMessage('<em>Escaped HTML</em>', 'good', true);
$form->setupFormErrors();
$parser = new CSSContentParser($form->forTemplate()); $parser = new CSSContentParser($form->forTemplate());
$messageEls = $parser->getBySelector('.message'); $messageEls = $parser->getBySelector('.message');
$this->assertContains( $this->assertContains(
@ -768,6 +797,7 @@ class FormTest extends FunctionalTest {
$form = $this->getStubForm(); $form = $this->getStubForm();
$form->getController()->handleRequest(new HTTPRequest('GET', '/'), DataModel::inst()); // stub out request $form->getController()->handleRequest(new HTTPRequest('GET', '/'), DataModel::inst()); // stub out request
$form->sessionMessage('<em>Unescaped HTML</em>', 'good', false); $form->sessionMessage('<em>Unescaped HTML</em>', 'good', false);
$form->setupFormErrors();
$parser = new CSSContentParser($form->forTemplate()); $parser = new CSSContentParser($form->forTemplate());
$messageEls = $parser->getBySelector('.message'); $messageEls = $parser->getBySelector('.message');
$this->assertContains( $this->assertContains(
@ -779,7 +809,7 @@ class FormTest extends FunctionalTest {
function testFieldMessageEscapeHtml() { function testFieldMessageEscapeHtml() {
$form = $this->getStubForm(); $form = $this->getStubForm();
$form->getController()->handleRequest(new HTTPRequest('GET', '/'), DataModel::inst()); // stub out request $form->getController()->handleRequest(new HTTPRequest('GET', '/'), DataModel::inst()); // stub out request
$form->addErrorMessage('key1', '<em>Escaped HTML</em>', 'good', true); $form->getSessionValidationResult()->addFieldMessage('key1', '<em>Escaped HTML</em>', 'good');
$form->setupFormErrors(); $form->setupFormErrors();
$parser = new CSSContentParser($result = $form->forTemplate()); $parser = new CSSContentParser($result = $form->forTemplate());
$messageEls = $parser->getBySelector('#Form_Form_key1_Holder .message'); $messageEls = $parser->getBySelector('#Form_Form_key1_Holder .message');
@ -790,7 +820,7 @@ class FormTest extends FunctionalTest {
$form = $this->getStubForm(); $form = $this->getStubForm();
$form->getController()->handleRequest(new HTTPRequest('GET', '/'), DataModel::inst()); // stub out request $form->getController()->handleRequest(new HTTPRequest('GET', '/'), DataModel::inst()); // stub out request
$form->addErrorMessage('key1', '<em>Unescaped HTML</em>', 'good', false); $form->getSessionValidationResult()->addFieldMessage('key1', '<em>Unescaped HTML</em>', 'good', null, false);
$form->setupFormErrors(); $form->setupFormErrors();
$parser = new CSSContentParser($form->forTemplate()); $parser = new CSSContentParser($form->forTemplate());
$messageEls = $parser->getBySelector('#Form_Form_key1_Holder .message'); $messageEls = $parser->getBySelector('#Form_Form_key1_Holder .message');

View File

@ -33,6 +33,16 @@ class HierarchyTest extends SapphireTest {
$obj2->ParentID = $obj2aa->ID; $obj2->ParentID = $obj2aa->ID;
$obj2->write(); $obj2->write();
} }
catch (ValidationException $e) {
$this->assertContains(
Convert::raw2xml('Infinite loop found within the "HierarchyTest_Object" hierarchy'),
$e->getMessage()
);
return;
}
$this->fail('Failed to prevent infinite loop in hierarchy.');
}
/** /**
* Test Hierarchy::AllHistoricalChildren(). * Test Hierarchy::AllHistoricalChildren().

View File

@ -13,12 +13,14 @@ class ValidationExceptionTest extends SapphireTest
*/ */
public function testCreateFromValidationResult() { public function testCreateFromValidationResult() {
$result = new ValidationResult(false, 'Not a valid result'); $result = new ValidationResult();
$result->addError('Not a valid result');
$exception = new ValidationException($result); $exception = new ValidationException($result);
$this->assertEquals(0, $exception->getCode()); $this->assertEquals(0, $exception->getCode());
$this->assertEquals('Not a valid result', $exception->getMessage()); $this->assertEquals('Not a valid result', $exception->getMessage());
$this->assertEquals(false, $exception->getResult()->valid()); $this->assertFalse($exception->getResult()->valid());
$this->assertEquals('Not a valid result', $exception->getResult()->message()); $this->assertEquals('Not a valid result', $exception->getResult()->message());
} }
@ -29,8 +31,8 @@ class ValidationExceptionTest extends SapphireTest
*/ */
public function testCreateFromComplexValidationResult() { public function testCreateFromComplexValidationResult() {
$result = new ValidationResult(); $result = new ValidationResult();
$result->error('Invalid type') $result->addError('Invalid type')
->error('Out of kiwis'); ->addError('Out of kiwis');
$exception = new ValidationException($result); $exception = new ValidationException($result);
$this->assertEquals(0, $exception->getCode()); $this->assertEquals(0, $exception->getCode());
@ -48,7 +50,7 @@ class ValidationExceptionTest extends SapphireTest
$this->assertEquals(E_USER_ERROR, $exception->getCode()); $this->assertEquals(E_USER_ERROR, $exception->getCode());
$this->assertEquals('Error inferred from message', $exception->getMessage()); $this->assertEquals('Error inferred from message', $exception->getMessage());
$this->assertEquals(false, $exception->getResult()->valid()); $this->assertFalse($exception->getResult()->valid());
$this->assertEquals('Error inferred from message', $exception->getResult()->message()); $this->assertEquals('Error inferred from message', $exception->getResult()->message());
} }
@ -57,12 +59,13 @@ class ValidationExceptionTest extends SapphireTest
* and a custom message * and a custom message
*/ */
public function testCreateWithValidationResultAndMessage() { public function testCreateWithValidationResultAndMessage() {
$result = new ValidationResult(false, 'Incorrect placement of cutlery'); $result = new ValidationResult();
$result->addError('Incorrect placement of cutlery');
$exception = new ValidationException($result, 'An error has occurred', E_USER_WARNING); $exception = new ValidationException($result, 'An error has occurred', E_USER_WARNING);
$this->assertEquals(E_USER_WARNING, $exception->getCode()); $this->assertEquals(E_USER_WARNING, $exception->getCode());
$this->assertEquals('An error has occurred', $exception->getMessage()); $this->assertEquals('An error has occurred', $exception->getMessage());
$this->assertEquals(false, $exception->getResult()->valid()); $this->assertFalse($exception->getResult()->valid());
$this->assertEquals('Incorrect placement of cutlery', $exception->getResult()->message()); $this->assertEquals('Incorrect placement of cutlery', $exception->getResult()->message());
} }
@ -73,8 +76,8 @@ class ValidationExceptionTest extends SapphireTest
*/ */
public function testCreateWithComplexValidationResultAndMessage() { public function testCreateWithComplexValidationResultAndMessage() {
$result = new ValidationResult(); $result = new ValidationResult();
$result->error('A spork is not a knife') $result->addError('A spork is not a knife')
->error('A knife is not a back scratcher'); ->addError('A knife is not a back scratcher');
$exception = new ValidationException($result, 'An error has occurred', E_USER_WARNING); $exception = new ValidationException($result, 'An error has occurred', E_USER_WARNING);
$this->assertEquals(E_USER_WARNING, $exception->getCode()); $this->assertEquals(E_USER_WARNING, $exception->getCode());
@ -91,8 +94,8 @@ class ValidationExceptionTest extends SapphireTest
$result = new ValidationResult(); $result = new ValidationResult();
$anotherresult = new ValidationResult(); $anotherresult = new ValidationResult();
$yetanotherresult = new ValidationResult(); $yetanotherresult = new ValidationResult();
$anotherresult->error("Eat with your mouth closed", "EATING101"); $anotherresult->addError("Eat with your mouth closed", 'bad', "EATING101");
$yetanotherresult->error("You didn't wash your hands", "BECLEAN"); $yetanotherresult->addError("You didn't wash your hands", 'bad', "BECLEAN", false);
$this->assertTrue($result->valid()); $this->assertTrue($result->valid());
$this->assertFalse($anotherresult->valid()); $this->assertFalse($anotherresult->valid());
@ -107,4 +110,50 @@ class ValidationExceptionTest extends SapphireTest
), $result->messageList()); ), $result->messageList());
} }
/**
* Test that a ValidationException created with no contained ValidationResult
* will correctly populate itself with an inferred version
*/
public function testCreateForField() {
$exception = ValidationException::create_for_field('Content', 'Content is required');
$this->assertEquals('Content is required', $exception->getMessage());
$this->assertEquals(false, $exception->getResult()->valid());
$this->assertEquals(array(
'Content' => array(
'message' => 'Content is required',
'messageType' => 'bad',
),
), $exception->getResult()->fieldErrors());
}
/**
* Test that a ValidationException created with no contained ValidationResult
* will correctly populate itself with an inferred version
*/
public function testValidationResultAddMethods() {
$result = new ValidationResult();
$result->addMessage('A spork is not a knife', 'bad');
$result->addError('A knife is not a back scratcher');
$result->addFieldMessage('Title', 'Title is good', 'good');
$result->addFieldError('Content', 'Content is bad');
$this->assertEquals(array(
'Title' => array(
'message' => 'Title is good',
'messageType' => 'good'
),
'Content' => array(
'message' => 'Content is bad',
'messageType' => 'bad'
)
), $result->fieldErrors());
$this->assertEquals('A spork is not a knife; A knife is not a back scratcher', $result->overallMessage());
$exception = ValidationException::create_for_field('Content', 'Content is required');
}
} }

View File

@ -139,6 +139,7 @@ class MemberAuthenticatorTest extends SapphireTest {
'tempid' => $tempID, 'tempid' => $tempID,
'Password' => 'mypassword' 'Password' => 'mypassword'
), $form); ), $form);
$form->setupFormErrors();
$this->assertNotEmpty($result); $this->assertNotEmpty($result);
$this->assertEquals($result->ID, $member->ID); $this->assertEquals($result->ID, $member->ID);
$this->assertEmpty($form->Message()); $this->assertEmpty($form->Message());
@ -149,8 +150,9 @@ class MemberAuthenticatorTest extends SapphireTest {
'tempid' => $tempID, 'tempid' => $tempID,
'Password' => 'notmypassword' 'Password' => 'notmypassword'
), $form); ), $form);
$form->setupFormErrors();
$this->assertEmpty($result); $this->assertEmpty($result);
$this->assertEquals('The provided details don&#039;t seem to be correct. Please try again.', $form->Message()); $this->assertEquals(Convert::raw2xml(_t('Member.ERRORWRONGCRED')), $form->Message());
$this->assertEquals('bad', $form->MessageType()); $this->assertEquals('bad', $form->MessageType());
} }
@ -168,6 +170,7 @@ class MemberAuthenticatorTest extends SapphireTest {
'Email' => 'admin', 'Email' => 'admin',
'Password' => 'password' 'Password' => 'password'
), $form); ), $form);
$form->setupFormErrors();
$this->assertNotEmpty($result); $this->assertNotEmpty($result);
$this->assertEquals($result->Email, Security::default_admin_username()); $this->assertEquals($result->Email, Security::default_admin_username());
$this->assertEmpty($form->Message()); $this->assertEmpty($form->Message());
@ -178,6 +181,7 @@ class MemberAuthenticatorTest extends SapphireTest {
'Email' => 'admin', 'Email' => 'admin',
'Password' => 'notmypassword' 'Password' => 'notmypassword'
), $form); ), $form);
$form->setupFormErrors();
$this->assertEmpty($result); $this->assertEmpty($result);
$this->assertEquals('The provided details don&#039;t seem to be correct. Please try again.', $form->Message()); $this->assertEquals('The provided details don&#039;t seem to be correct. Please try again.', $form->Message());
$this->assertEquals('bad', $form->MessageType()); $this->assertEquals('bad', $form->MessageType());

View File

@ -444,7 +444,7 @@ class SecurityTest extends FunctionalTest {
$member->LockedOutUntil, $member->LockedOutUntil,
'User does not have a lockout time set if under threshold for failed attempts' 'User does not have a lockout time set if under threshold for failed attempts'
); );
$this->assertContains($this->loginErrorMessage(), Convert::raw2xml(_t('Member.ERRORWRONGCRED'))); $this->assertContains(Convert::raw2xml(_t('Member.ERRORWRONGCRED')), $this->loginErrorMessage());
} else { } else {
// Fuzzy matching for time to avoid side effects from slow running tests // Fuzzy matching for time to avoid side effects from slow running tests
$this->assertGreaterThan( $this->assertGreaterThan(
@ -644,7 +644,8 @@ class SecurityTest extends FunctionalTest {
* Get the error message on the login form * Get the error message on the login form
*/ */
public function loginErrorMessage() { public function loginErrorMessage() {
return $this->session()->inst_get('FormInfo.MemberLoginForm_LoginForm.formError.message'); $result = $this->session()->inst_get('FormInfo.MemberLoginForm_LoginForm.result');
return $result->message();
} }
} }