From e5034e2aae9fb5f4f1d0bd6e2702bafa9dea673b Mon Sep 17 00:00:00 2001
From: Rick Herrick <jrherrick@wustl.edu>
Date: Wed, 7 Sep 2016 16:10:58 -0500
Subject: [PATCH] XNAT-2840 Fallout from user creation, listing, and
 invalidation functions. Cleaned up user class to prevent marshalling of
 passwords, but handle incoming password values properly. Separated create and
 update methods. Made error reporting more robust.

---
 build.gradle                                  |  13 +-
 .../{rest => exceptions}/ApiException.java    |   2 +-
 .../xapi/exceptions/DataFormatException.java  |  91 ++++++++++
 .../xapi/exceptions/NotFoundException.java    |  11 ++
 .../ResourceAlreadyExistsException.java       |  11 ++
 .../java/org/nrg/xapi/model/users/User.java   |  17 +-
 .../org/nrg/xapi/rest/NotFoundException.java  |   7 -
 .../xapi/rest/XapiRestControllerAdvice.java   |  12 ++
 .../nrg/xapi/rest/dicomscp/DicomSCPApi.java   |   2 +-
 .../org/nrg/xapi/rest/theme/ThemeApi.java     |   2 +-
 .../org/nrg/xapi/rest/users/UsersApi.java     | 164 ++++++++++++++----
 .../model/users/TestUserSerialization.java    |  61 +++++++
 .../users/TestUserSerializationConfig.java    |  38 ++++
 src/test/resources/log4j.properties           |  14 ++
 14 files changed, 389 insertions(+), 56 deletions(-)
 rename src/main/java/org/nrg/xapi/{rest => exceptions}/ApiException.java (87%)
 create mode 100644 src/main/java/org/nrg/xapi/exceptions/DataFormatException.java
 create mode 100644 src/main/java/org/nrg/xapi/exceptions/NotFoundException.java
 create mode 100644 src/main/java/org/nrg/xapi/exceptions/ResourceAlreadyExistsException.java
 delete mode 100644 src/main/java/org/nrg/xapi/rest/NotFoundException.java
 create mode 100644 src/test/java/org/nrg/xapi/model/users/TestUserSerialization.java
 create mode 100644 src/test/java/org/nrg/xapi/model/users/TestUserSerializationConfig.java
 create mode 100644 src/test/resources/log4j.properties

diff --git a/build.gradle b/build.gradle
index 173b0b42..f8b63ac9 100644
--- a/build.gradle
+++ b/build.gradle
@@ -14,7 +14,6 @@ def vCargo = '1.4.18'
 def vSlf4j = '1.7.21'
 def vLog4j = '1.2.17'
 def vJunit = '4.12'
-// def vSaxon = '9.7.0-7'
 def vSaxon = '9.1.0.8'
 def vGroovy = '2.4.6'
 def vJython = '2.7.0'
@@ -50,6 +49,7 @@ apply plugin: 'groovy'
 apply plugin: 'java'
 apply plugin: 'war'
 apply plugin: 'maven'
+apply plugin: 'jacoco'
 apply plugin: 'maven-publish'
 apply plugin: 'com.bmuschko.tomcat'
 apply plugin: 'com.bmuschko.cargo'
@@ -167,6 +167,17 @@ war {
                 'Implementation-Version': version
     }
 }
+jacoco {
+    toolVersion = "0.7.6.201602180812"
+}
+
+jacocoTestReport {
+    reports {
+        xml.enabled false
+        csv.enabled false
+        html.destination "${buildDir}/jacocoHtml"
+    }
+}
 
 task sourceJar(type: Jar, dependsOn: classes) {
     from sourceSets.main.allSource
diff --git a/src/main/java/org/nrg/xapi/rest/ApiException.java b/src/main/java/org/nrg/xapi/exceptions/ApiException.java
similarity index 87%
rename from src/main/java/org/nrg/xapi/rest/ApiException.java
rename to src/main/java/org/nrg/xapi/exceptions/ApiException.java
index f6e34b40..2664e319 100644
--- a/src/main/java/org/nrg/xapi/rest/ApiException.java
+++ b/src/main/java/org/nrg/xapi/exceptions/ApiException.java
@@ -1,4 +1,4 @@
-package org.nrg.xapi.rest;
+package org.nrg.xapi.exceptions;
 
 public class ApiException extends Exception {
     public ApiException(int code, String msg) {
diff --git a/src/main/java/org/nrg/xapi/exceptions/DataFormatException.java b/src/main/java/org/nrg/xapi/exceptions/DataFormatException.java
new file mode 100644
index 00000000..8af6ada6
--- /dev/null
+++ b/src/main/java/org/nrg/xapi/exceptions/DataFormatException.java
@@ -0,0 +1,91 @@
+package org.nrg.xapi.exceptions;
+
+import com.google.common.base.Joiner;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.ResponseStatus;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@ResponseStatus(HttpStatus.BAD_REQUEST)
+public class DataFormatException extends ApiException {
+    private final List<String>        _missing = new ArrayList<>();
+    private final List<String>        _unknown = new ArrayList<>();
+    private final Map<String, String> _invalid = new HashMap<>();
+
+    public DataFormatException() {
+        this("There was an error with the submitted data");
+    }
+
+    public DataFormatException(final String message) {
+        super(HttpStatus.BAD_REQUEST.value(), message);
+    }
+
+    public List<String> getMissingFields() {
+        return _missing;
+    }
+
+    public void setMissing(final List<String> missing) {
+        _missing.clear();
+        _missing.addAll(missing);
+    }
+
+    public void addMissing(final String missing) {
+        _missing.add(missing);
+    }
+
+    public List<String> getUnknownFields() {
+        return _unknown;
+    }
+
+    public void setUnknown(final List<String> unknown) {
+        _unknown.clear();
+        _unknown.addAll(unknown);
+    }
+
+    public void addUnknown(final String unknown) {
+        _unknown.add(unknown);
+    }
+
+    public Map<String, String> getInvalidFields() {
+        return _invalid;
+    }
+
+    public void setInvalid(final Map<String, String> invalid) {
+        _invalid.clear();
+        _invalid.putAll(invalid);
+    }
+
+    public void addInvalid(final String invalid) {
+        _invalid.put(invalid, "Invalid " + invalid + " format");
+    }
+
+    public void addInvalid(final String invalid, final String message) {
+        _invalid.put(invalid, message);
+    }
+
+    public boolean hasDataFormatErrors() {
+        return !_missing.isEmpty() && !_unknown.isEmpty() && !_invalid.isEmpty();
+    }
+
+    @Override
+    public String getMessage() {
+        final StringBuilder buffer = new StringBuilder(super.getMessage());
+        buffer.append("\n");
+        if (_missing.size() > 0) {
+            buffer.append(" * Missing fields: ").append(Joiner.on(", ").join(_missing)).append("\n");
+        }
+        if (_unknown.size() > 0) {
+            buffer.append(" * Unknown fields: ").append(Joiner.on(", ").join(_unknown)).append("\n");
+        }
+        if (_invalid.size() > 0) {
+            buffer.append(" * Invalid fields:\n");
+            for (final String invalid : _invalid.keySet()) {
+                buffer.append("    - ").append(invalid).append(": ").append(_invalid.get(invalid)).append("\n");
+            }
+        }
+        return buffer.toString();
+    }
+}
diff --git a/src/main/java/org/nrg/xapi/exceptions/NotFoundException.java b/src/main/java/org/nrg/xapi/exceptions/NotFoundException.java
new file mode 100644
index 00000000..4315274b
--- /dev/null
+++ b/src/main/java/org/nrg/xapi/exceptions/NotFoundException.java
@@ -0,0 +1,11 @@
+package org.nrg.xapi.exceptions;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.ResponseStatus;
+
+@ResponseStatus(HttpStatus.NOT_FOUND)
+public class NotFoundException extends ApiException {
+    public NotFoundException(String msg) {
+        super(HttpStatus.NOT_FOUND.value(), msg);
+    }
+}
diff --git a/src/main/java/org/nrg/xapi/exceptions/ResourceAlreadyExistsException.java b/src/main/java/org/nrg/xapi/exceptions/ResourceAlreadyExistsException.java
new file mode 100644
index 00000000..fd39f298
--- /dev/null
+++ b/src/main/java/org/nrg/xapi/exceptions/ResourceAlreadyExistsException.java
@@ -0,0 +1,11 @@
+package org.nrg.xapi.exceptions;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.ResponseStatus;
+
+@ResponseStatus(HttpStatus.CONFLICT)
+public class ResourceAlreadyExistsException extends ApiException {
+    public ResourceAlreadyExistsException(final String type, final String name) {
+        super(HttpStatus.CONFLICT.value(), "The resource of type " + type + " with the name " + name + " already exists.");
+    }
+}
diff --git a/src/main/java/org/nrg/xapi/model/users/User.java b/src/main/java/org/nrg/xapi/model/users/User.java
index b8b7574e..9e0f751e 100644
--- a/src/main/java/org/nrg/xapi/model/users/User.java
+++ b/src/main/java/org/nrg/xapi/model/users/User.java
@@ -1,6 +1,7 @@
 package org.nrg.xapi.model.users;
 
 import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
 import io.swagger.annotations.ApiModel;
 import io.swagger.annotations.ApiModelProperty;
 import org.nrg.xdat.entities.UserAuthI;
@@ -106,7 +107,7 @@ public class User {
      * Whether the user is a site administrator.
      **/
     @ApiModelProperty(value = "Whether the user is a site administrator.")
-    public boolean isAdmin() {
+    public Boolean isAdmin() {
         return _isAdmin;
     }
 
@@ -118,7 +119,7 @@ public class User {
      * Whether the user is enabled.
      **/
     @ApiModelProperty(value = "Whether the user is enabled.")
-    public boolean isEnabled() {
+    public Boolean isEnabled() {
         return _isEnabled;
     }
 
@@ -130,7 +131,7 @@ public class User {
      * Whether the user is verified.
      **/
     @ApiModelProperty(value = "Whether the user is verified.")
-    public boolean isVerified() {
+    public Boolean isVerified() {
         return _isVerified;
     }
 
@@ -142,7 +143,7 @@ public class User {
      * The user's encrypted password.
      **/
     @ApiModelProperty(value = "The user's encrypted password.")
-    @JsonIgnore
+    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
     public String getPassword() {
         return _password;
     }
@@ -155,7 +156,7 @@ public class User {
      * The _salt used to encrypt the user's _password.
      **/
     @ApiModelProperty(value = "The salt used to encrypt the user's password.")
-    @JsonIgnore
+    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
     public String getSalt() {
         return _salt;
     }
@@ -222,7 +223,7 @@ public class User {
     private String    _salt;
     private Date      _lastModified;
     private UserAuthI _authorization;
-    private boolean   _isAdmin;
-    private boolean   _isEnabled;
-    private boolean   _isVerified;
+    private Boolean   _isAdmin;
+    private Boolean   _isEnabled;
+    private Boolean   _isVerified;
 }
diff --git a/src/main/java/org/nrg/xapi/rest/NotFoundException.java b/src/main/java/org/nrg/xapi/rest/NotFoundException.java
deleted file mode 100644
index b42ca702..00000000
--- a/src/main/java/org/nrg/xapi/rest/NotFoundException.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package org.nrg.xapi.rest;
-
-public class NotFoundException extends ApiException {
-    public NotFoundException(int code, String msg) {
-        super(code, msg);
-    }
-}
diff --git a/src/main/java/org/nrg/xapi/rest/XapiRestControllerAdvice.java b/src/main/java/org/nrg/xapi/rest/XapiRestControllerAdvice.java
index 07a3712c..5775a149 100644
--- a/src/main/java/org/nrg/xapi/rest/XapiRestControllerAdvice.java
+++ b/src/main/java/org/nrg/xapi/rest/XapiRestControllerAdvice.java
@@ -4,6 +4,8 @@ import org.nrg.dcm.exceptions.EnabledDICOMReceiverWithDuplicatePortException;
 import org.nrg.framework.exceptions.NrgServiceError;
 import org.nrg.framework.exceptions.NrgServiceException;
 import org.nrg.framework.exceptions.NrgServiceRuntimeException;
+import org.nrg.xapi.exceptions.DataFormatException;
+import org.nrg.xapi.exceptions.ResourceAlreadyExistsException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.core.annotation.AnnotationUtils;
@@ -26,6 +28,16 @@ public class XapiRestControllerAdvice {
         return handleException(request, exception.getMessage());
     }
 
+    @ExceptionHandler(DataFormatException.class)
+    public ModelAndView handleDataFormatException(final HttpServletRequest request, final DataFormatException exception) {
+        return handleException(request, exception.getMessage(), exception);
+    }
+
+    @ExceptionHandler(ResourceAlreadyExistsException.class)
+    public ModelAndView handleDataFormatException(final HttpServletRequest request, final ResourceAlreadyExistsException exception) {
+        return handleException(request, exception.getMessage(), exception);
+    }
+
     @ExceptionHandler(NrgServiceException.class)
     public ModelAndView handleNrgServiceException(final HttpServletRequest request, final NrgServiceException exception) {
         return handleException(HttpStatus.CONFLICT, request, "An NRG service error occurred.", exception);
diff --git a/src/main/java/org/nrg/xapi/rest/dicomscp/DicomSCPApi.java b/src/main/java/org/nrg/xapi/rest/dicomscp/DicomSCPApi.java
index ece0cd4d..140d2945 100644
--- a/src/main/java/org/nrg/xapi/rest/dicomscp/DicomSCPApi.java
+++ b/src/main/java/org/nrg/xapi/rest/dicomscp/DicomSCPApi.java
@@ -7,7 +7,7 @@ import org.nrg.dcm.exceptions.EnabledDICOMReceiverWithDuplicatePortException;
 import org.nrg.dcm.preferences.DicomSCPInstance;
 import org.nrg.framework.annotations.XapiRestController;
 import org.nrg.framework.exceptions.NrgServiceException;
-import org.nrg.xapi.rest.NotFoundException;
+import org.nrg.xapi.exceptions.NotFoundException;
 import org.nrg.xdat.rest.AbstractXapiRestController;
 import org.nrg.xdat.security.services.RoleHolder;
 import org.nrg.xdat.security.services.UserManagementServiceI;
diff --git a/src/main/java/org/nrg/xapi/rest/theme/ThemeApi.java b/src/main/java/org/nrg/xapi/rest/theme/ThemeApi.java
index b650d8d0..5dd40e81 100644
--- a/src/main/java/org/nrg/xapi/rest/theme/ThemeApi.java
+++ b/src/main/java/org/nrg/xapi/rest/theme/ThemeApi.java
@@ -14,7 +14,7 @@ package org.nrg.xapi.rest.theme;
 import io.swagger.annotations.*;
 import org.apache.commons.io.FileUtils;
 import org.nrg.framework.annotations.XapiRestController;
-import org.nrg.xapi.rest.NotFoundException;
+import org.nrg.xapi.exceptions.NotFoundException;
 import org.nrg.xdat.entities.ThemeConfig;
 import org.nrg.xdat.rest.AbstractXapiRestController;
 import org.nrg.xdat.security.services.RoleHolder;
diff --git a/src/main/java/org/nrg/xapi/rest/users/UsersApi.java b/src/main/java/org/nrg/xapi/rest/users/UsersApi.java
index 118075f8..3e687d89 100644
--- a/src/main/java/org/nrg/xapi/rest/users/UsersApi.java
+++ b/src/main/java/org/nrg/xapi/rest/users/UsersApi.java
@@ -7,8 +7,11 @@ import org.jetbrains.annotations.Nullable;
 import org.nrg.framework.annotations.XapiRestController;
 import org.nrg.framework.exceptions.NrgServiceError;
 import org.nrg.framework.exceptions.NrgServiceRuntimeException;
+import org.nrg.framework.utilities.Patterns;
+import org.nrg.xapi.exceptions.DataFormatException;
+import org.nrg.xapi.exceptions.ResourceAlreadyExistsException;
 import org.nrg.xapi.model.users.User;
-import org.nrg.xapi.rest.NotFoundException;
+import org.nrg.xapi.exceptions.NotFoundException;
 import org.nrg.xdat.XDAT;
 import org.nrg.xdat.preferences.SiteConfigPreferences;
 import org.nrg.xdat.rest.AbstractXapiRestController;
@@ -194,70 +197,113 @@ public class UsersApi extends AbstractXapiRestController {
         }
     }
 
-    @ApiOperation(value = "Creates or updates the user object with the specified username.", notes = "Returns the updated serialized user object with the specified username.", response = User.class)
-    @ApiResponses({@ApiResponse(code = 200, message = "User successfully created or updated."),
+    @ApiOperation(value = "Updates the user object with the specified username.", notes = "Returns the updated serialized user object with the specified username.", response = User.class)
+    @ApiResponses({@ApiResponse(code = 201, message = "User successfully created."),
+                   @ApiResponse(code = 400, message = "The submitted data was invalid."),
                    @ApiResponse(code = 401, message = "Must be authenticated to access the XNAT REST API."),
-                   @ApiResponse(code = 403, message = "Not authorized to create or update this user."),
+                   @ApiResponse(code = 403, message = "Not authorized to update this user."),
+                   @ApiResponse(code = 500, message = "An unexpected error occurred.")})
+    @RequestMapping(produces = MediaType.APPLICATION_JSON_VALUE, method = RequestMethod.POST)
+    public ResponseEntity<User> createUser(@RequestBody final User model) throws NotFoundException, PasswordComplexityException, DataFormatException, UserInitException, ResourceAlreadyExistsException {
+        final HttpStatus status = isPermitted();
+        if (status != null) {
+            return new ResponseEntity<>(status);
+        }
+
+        validateUser(model);
+
+        final UserI user = getUserManagementService().createUser();
+
+        if (user == null) {
+            throw new NrgServiceRuntimeException(NrgServiceError.Unknown, "Failed to create a user object for user " + model.getUsername());
+        }
+
+        user.setLogin(model.getUsername());
+        user.setFirstname(model.getFirstName());
+        user.setLastname(model.getLastName());
+        user.setEmail(model.getEmail());
+        if (model.isEnabled() != null) {
+            user.setEnabled(model.isEnabled());
+        }
+        if (model.isVerified()) {
+            user.setVerified(model.isVerified());
+        }
+        user.setPassword(model.getPassword());
+        user.setAuthorization(model.getAuthorization());
+
+        fixPassword(user);
+
+        try {
+            getUserManagementService().save(user, getSessionUser(), false, new EventDetails(EventUtils.CATEGORY.DATA, EventUtils.TYPE.WEB_SERVICE, Event.Added, "Requested by user " + getSessionUser().getUsername(), "Created new user " + user.getUsername() + " through XAPI user management API."));
+            return new ResponseEntity<>(new User(user), HttpStatus.CREATED);
+        } catch (Exception e) {
+            _log.error("Error occurred modifying user " + user.getLogin());
+        }
+        return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
+    }
+
+    @ApiOperation(value = "Updates the user object with the specified username.", notes = "Returns the updated serialized user object with the specified username.", response = User.class)
+    @ApiResponses({@ApiResponse(code = 200, message = "User successfully updated."),
+                   @ApiResponse(code = 401, message = "Must be authenticated to access the XNAT REST API."),
+                   @ApiResponse(code = 403, message = "Not authorized to update this user."),
                    @ApiResponse(code = 404, message = "User not found."),
                    @ApiResponse(code = 500, message = "An unexpected error occurred.")})
     @RequestMapping(value = "{username}", produces = MediaType.APPLICATION_JSON_VALUE, method = RequestMethod.PUT)
-    public ResponseEntity<User> usersIdPut(@ApiParam(value = "The username of the user to create or update.", required = true) @PathVariable("username") String username, @RequestBody User model) throws NotFoundException, PasswordComplexityException {
-        HttpStatus status = isPermitted(username);
+    public ResponseEntity<User> updateUser(@ApiParam(value = "The username of the user to create or update.", required = true) @PathVariable("username") final String username, @RequestBody final User model) throws NotFoundException, PasswordComplexityException, UserInitException {
+        final HttpStatus status = isPermitted(username);
         if (status != null) {
             return new ResponseEntity<>(status);
         }
-        UserI user;
+
+        final UserI user;
         try {
             user = getUserManagementService().getUser(username);
-        } catch (Exception e) {
-            //Create new User
-            user = getUserManagementService().createUser();
-            user.setLogin(username);
+        } catch (UserNotFoundException e) {
+            throw new NotFoundException("User with username " + username + " was not found.");
         }
+
         if (user == null) {
-            throw new NrgServiceRuntimeException(NrgServiceError.Unknown, "Failed to retrieve or create a user object for user " + username);
+            throw new NrgServiceRuntimeException(NrgServiceError.Unknown, "Failed to retrieve user object for user " + username);
         }
-        if ((StringUtils.isNotBlank(model.getFirstName())) && (!StringUtils.equals(model.getFirstName(), user.getFirstname()))) {
+
+        if ((StringUtils.isNotBlank(model.getUsername())) && (!StringUtils.equals(user.getUsername(), model.getUsername()))) {
+            user.setLogin(model.getUsername());
+        }
+        if ((StringUtils.isNotBlank(model.getFirstName())) && (!StringUtils.equals(user.getFirstname(), model.getFirstName()))) {
             user.setFirstname(model.getFirstName());
         }
-        if ((StringUtils.isNotBlank(model.getLastName())) && (!StringUtils.equals(model.getLastName(), user.getLastname()))) {
+        if ((StringUtils.isNotBlank(model.getLastName())) && (!StringUtils.equals(user.getLastname(), model.getLastName()))) {
             user.setLastname(model.getLastName());
         }
-        if ((StringUtils.isNotBlank(model.getEmail())) && (!StringUtils.equals(model.getEmail(), user.getEmail()))) {
+        if ((StringUtils.isNotBlank(model.getEmail())) && (!StringUtils.equals(user.getEmail(), model.getEmail()))) {
             user.setEmail(model.getEmail());
         }
-        if (model.isEnabled() != user.isEnabled()) {
+        if ((StringUtils.isNotBlank(model.getPassword())) && (!StringUtils.equals(user.getPassword(), model.getPassword()))) {
+            user.setPassword(model.getPassword());
+            fixPassword(user);
+        }
+        if (model.getAuthorization() != null && !model.getAuthorization().equals(user.getAuthorization())) {
+            user.setAuthorization(model.getAuthorization());
+        }
+        if (model.isEnabled() != null) {
             user.setEnabled(model.isEnabled());
-            if (!model.isEnabled()) {
-                //When a user is disabled, deactivate all their AliasTokens
-                try {
-                    XDAT.getContextService().getBean(AliasTokenService.class).deactivateAllTokensForUser(user.getLogin());
-                } catch (Exception e) {
-                    _log.error("", e);
-                }
-            }
         }
-        if (model.isVerified() != user.isVerified()) {
+        if (model.isVerified() != null) {
             user.setVerified(model.isVerified());
         }
 
-        final String password;
-        if (StringUtils.isNotBlank(model.getPassword())) {
-            password = model.getPassword();
-            if (!_passwordValidator.isValid(password, user)) {
-                throw new PasswordComplexityException(_passwordValidator.getMessage());
+        if (!user.isEnabled()) {
+            //When a user is disabled, deactivate all their AliasTokens
+            try {
+                XDAT.getContextService().getBean(AliasTokenService.class).deactivateAllTokensForUser(user.getLogin());
+            } catch (Exception e) {
+                _log.error("", e);
             }
-        } else {
-            password = RandomStringUtils.randomAscii(32);
         }
-        final String salt = Users.createNewSalt();
-        user.setPassword(new ShaPasswordEncoder(256).encodePassword(password, salt));
-        user.setPrimaryPassword_encrypt(true);
-        user.setSalt(salt);
 
         try {
             getUserManagementService().save(user, getSessionUser(), false, new EventDetails(EventUtils.CATEGORY.DATA, EventUtils.TYPE.WEB_SERVICE, Event.Modified, "", ""));
-            return new ResponseEntity<>(HttpStatus.OK);
+            return new ResponseEntity<>(new User(user), HttpStatus.OK);
         } catch (Exception e) {
             _log.error("Error occurred modifying user " + user.getLogin());
         }
@@ -613,6 +659,50 @@ public class UsersApi extends AbstractXapiRestController {
         return located;
     }
 
+    private void validateUser(final User model) throws DataFormatException, UserInitException, ResourceAlreadyExistsException {
+        final DataFormatException exception = new DataFormatException();
+
+        if (StringUtils.isBlank(model.getUsername())) {
+            exception.addMissing("username");
+        } else if (!Patterns.USERNAME.matcher(model.getUsername()).matches()) {
+            exception.addInvalid("username");
+        }
+
+        try {
+            final UserI user = getUserManagementService().getUser(model.getUsername());
+            if (user != null) {
+                throw new ResourceAlreadyExistsException("user", model.getUsername());
+            }
+        } catch (UserNotFoundException ignored) {
+            // This is actually what we want.
+        }
+
+        if (StringUtils.isBlank(model.getEmail())) {
+            exception.addMissing("email");
+        } else if (!Patterns.EMAIL.matcher(model.getEmail()).matches()) {
+            exception.addInvalid("email");
+        }
+
+        if (exception.hasDataFormatErrors()) {
+            throw exception;
+        }
+    }
+
+    private void fixPassword(final UserI user) throws PasswordComplexityException {
+        final String password = user.getPassword();
+        if (StringUtils.isNotBlank(password)) {
+            if (!_passwordValidator.isValid(password, user)) {
+                throw new PasswordComplexityException(_passwordValidator.getMessage());
+            }
+        } else {
+            user.setPassword(RandomStringUtils.randomAscii(32));
+        }
+        final String salt = Users.createNewSalt();
+        user.setPassword(new ShaPasswordEncoder(256).encodePassword(password, salt));
+        user.setPrimaryPassword_encrypt(true);
+        user.setSalt(salt);
+    }
+
     @SuppressWarnings("unused")
     public static class Event {
         public static String Added                 = "Added User";
diff --git a/src/test/java/org/nrg/xapi/model/users/TestUserSerialization.java b/src/test/java/org/nrg/xapi/model/users/TestUserSerialization.java
new file mode 100644
index 00000000..8ac87ebd
--- /dev/null
+++ b/src/test/java/org/nrg/xapi/model/users/TestUserSerialization.java
@@ -0,0 +1,61 @@
+package org.nrg.xapi.model.users;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import org.apache.commons.lang3.StringUtils;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.nrg.framework.services.SerializerService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
+
+import java.io.IOException;
+
+import static org.junit.Assert.*;
+
+@RunWith(SpringJUnit4ClassRunner.class)
+@ContextConfiguration(classes = TestUserSerializationConfig.class)
+public class TestUserSerialization {
+    @Autowired
+    public void setSerializer(final SerializerService serializer) {
+        _serializer = serializer;
+    }
+
+    @Test
+    public void testHiddenProperties() throws IOException {
+        final User input = new User();
+        input.setUsername("name");
+        input.setEmail("foo@bar.com");
+        input.setPassword("password");
+        input.setSalt("salt");
+        input.setAdmin(false);
+        input.setEnabled(true);
+
+        final String json = _serializer.toJson(input);
+        assertNotNull(json);
+        assertTrue(StringUtils.isNotBlank(json));
+
+        // Here's where we make sure the password and salt aren't serialized.
+        final JsonNode map = _serializer.deserializeJson(json);
+        assertNotNull(map);
+        assertTrue(map.has("username"));
+        assertTrue(map.has("email"));
+        assertFalse(map.has("password"));
+        assertFalse(map.has("salt"));
+        assertTrue(map.has("admin"));
+        assertTrue(map.has("enabled"));
+        assertFalse(map.has("verified"));
+
+        final User output = _serializer.deserializeJson(json, User.class);
+        assertNotNull(output);
+        assertTrue(StringUtils.isNotBlank(output.getUsername()));
+        assertTrue(StringUtils.isNotBlank(output.getEmail()));
+        assertTrue(StringUtils.isBlank(output.getPassword()));
+        assertTrue(StringUtils.isBlank(output.getSalt()));
+        assertFalse(output.isAdmin());
+        assertTrue(output.isEnabled());
+        assertNull(output.isVerified());
+    }
+
+    private SerializerService _serializer;
+}
diff --git a/src/test/java/org/nrg/xapi/model/users/TestUserSerializationConfig.java b/src/test/java/org/nrg/xapi/model/users/TestUserSerializationConfig.java
new file mode 100644
index 00000000..a9b209fe
--- /dev/null
+++ b/src/test/java/org/nrg/xapi/model/users/TestUserSerializationConfig.java
@@ -0,0 +1,38 @@
+package org.nrg.xapi.model.users;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.datatype.hibernate4.Hibernate4Module;
+import org.nrg.framework.beans.Beans;
+import org.nrg.framework.exceptions.NrgServiceException;
+import org.nrg.framework.services.SerializerService;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
+
+import java.util.Map;
+
+@Configuration
+public class TestUserSerializationConfig {
+    @Bean
+    public Jackson2ObjectMapperBuilder objectMapperBuilder() throws NrgServiceException {
+        return new Jackson2ObjectMapperBuilder()
+                .serializationInclusion(JsonInclude.Include.NON_NULL)
+                .failOnEmptyBeans(false)
+                .mixIns(mixIns())
+                .featuresToEnable(JsonParser.Feature.ALLOW_SINGLE_QUOTES, JsonParser.Feature.ALLOW_YAML_COMMENTS)
+                .featuresToDisable(SerializationFeature.FAIL_ON_EMPTY_BEANS, SerializationFeature.WRITE_NULL_MAP_VALUES)
+                .modulesToInstall(new Hibernate4Module());
+    }
+
+    @Bean
+    public SerializerService serializerService(final Jackson2ObjectMapperBuilder objectMapperBuilder) {
+        return new SerializerService(objectMapperBuilder);
+    }
+
+    @Bean
+    public Map<Class<?>, Class<?>> mixIns() throws NrgServiceException {
+        return Beans.getMixIns();
+    }
+}
diff --git a/src/test/resources/log4j.properties b/src/test/resources/log4j.properties
new file mode 100644
index 00000000..f5b31a8e
--- /dev/null
+++ b/src/test/resources/log4j.properties
@@ -0,0 +1,14 @@
+#
+# log4j.properties
+# XNAT http://www.xnat.org
+# Copyright (c) 2016, Washington University School of Medicine
+# All Rights Reserved
+#
+# Released under the Simplified BSD.
+#
+log4j.rootLogger=DEBUG, test
+
+log4j.appender.test=org.nrg.log4j.NRGFileAppender
+log4j.appender.test.File=build/test-results/xnat-web-test.log
+log4j.appender.test.layout=org.apache.log4j.PatternLayout
+log4j.appender.test.layout.ConversionPattern=%p %t %c - %m%n
-- 
GitLab