diff --git a/signup/src/org/labkey/signup/SignUpController.java b/signup/src/org/labkey/signup/SignUpController.java index db2fc50d..127febe0 100644 --- a/signup/src/org/labkey/signup/SignUpController.java +++ b/signup/src/org/labkey/signup/SignUpController.java @@ -16,6 +16,7 @@ package org.labkey.signup; +import jakarta.mail.MessagingException; import org.apache.commons.lang3.StringUtils; import org.apache.commons.validator.routines.EmailValidator; import org.apache.logging.log4j.LogManager; @@ -54,6 +55,7 @@ import org.labkey.api.security.permissions.ReadPermission; import org.labkey.api.settings.LookAndFeelProperties; import org.labkey.api.util.ButtonBuilder; +import org.labkey.api.util.ConfigurationException; import org.labkey.api.util.DOM; import org.labkey.api.util.PageFlowUtil; import org.labkey.api.util.URLHelper; @@ -62,10 +64,12 @@ import org.labkey.api.view.HtmlView; import org.labkey.api.view.HttpView; import org.labkey.api.view.JspView; +import org.labkey.api.view.LabKeyKaptchaServlet; import org.labkey.api.view.NavTree; import org.labkey.api.view.WebPartView; import org.springframework.validation.BindException; import org.springframework.validation.Errors; +import org.springframework.validation.ObjectError; import org.springframework.web.servlet.ModelAndView; import java.util.ArrayList; @@ -527,7 +531,8 @@ public class BeginAction extends FormViewAction @Override public void validateCommand(SignupForm form, Errors errors) { - validateSignupForm(form, errors); + // All validation happens in handlePost so the order matches SignUpApiAction + // (captcha first, then blank-field checks, then email parsing, etc.). } @Override @@ -559,27 +564,26 @@ public ModelAndView getView(SignupForm form, boolean reshow, BindException error @Override public boolean handlePost(SignupForm signupForm, BindException errors) throws Exception { - // Validate with EmailValidator first. ValidEmail(email) will not throw an exception if the domain is - // missing from the email. The default domain configured for the server is appended. - EmailValidator validator = EmailValidator.getInstance(); - if(!validator.isValid(signupForm.getEmail())) + String kaptchaError = verifyCaptcha(signupForm.getKaptchaText(), signupForm.getEmail()); + if (kaptchaError != null) { - errors.reject(ERROR_MSG,"'" + signupForm.getEmail() + "' is not a valid email address."); + errors.reject(ERROR_MSG, kaptchaError); return false; } - ValidEmail email; - try + validateSignupForm(signupForm, errors); + if (errors.hasErrors()) { - email = new ValidEmail(signupForm.getEmail()); + return false; } - catch (ValidEmail.InvalidEmailException iee) + + ValidEmail email = parseAndValidateEmail(signupForm, errors); + if (email == null) { - errors.reject(ERROR_MSG, iee.getMessage()); return false; } - if(UserManager.userExists(email)) + if (UserManager.userExists(email)) { // If the user already exists forward them to a page where they can click on a link to recover their password, if required signupForm.setAccountExists(true); @@ -587,26 +591,23 @@ public boolean handlePost(SignupForm signupForm, BindException errors) throws Ex return false; } - // If the user does not exit in LabKey's core database, check in our temporaryusers table - TempUser tempUser = getTempUser(signupForm, email); - - // Send email to the user. - ActionURL confirmationUrl = getConfirmationURL(getContainer(), email, tempUser.getKey()); try { - User mockUser = new User(); - mockUser.setEmail(email.getEmailAddress()); - SecurityManager.sendEmail(getContainer(), mockUser, SecurityManager.getRegistrationMessage(null, false), email.getEmailAddress(), confirmationUrl); + createUserAndSendEmail(signupForm, email); } - catch(Exception e) + catch (MessagingException | ConfigurationException e) { - String systemEmail = LookAndFeelProperties.getInstance(getContainer()).getSystemEmailAddress(); - errors.reject(ERROR_MSG, "Could not send new user registration email. Please contact your server administrator at " + systemEmail); - errors.reject(ERROR_MSG, e.getMessage()); + errors.reject(ERROR_MSG, sendEmailErrorMessage(getContainer())); + if (e.getMessage() != null) + { + errors.reject(ERROR_MSG, e.getMessage()); + } return false; } + clearCaptcha(); + // Re-render the JSP with the CONFIRMATION_SENT message. signupForm.setNewSignUp(false); return false; } @@ -641,6 +642,94 @@ private void validateSignupForm(SignupForm form, Errors errors) { errors.reject(ERROR_MSG, "Email cannot be blank."); } + if(StringUtils.isBlank(form.getEmailConfirm())) + { + errors.reject(ERROR_MSG, "Confirm email cannot be blank."); + } + else if(!StringUtils.isBlank(form.getEmail()) && !form.getEmail().equalsIgnoreCase(form.getEmailConfirm())) + { + errors.reject(ERROR_MSG, "Email addresses do not match."); + } + } + + // On success returns null. On failure returns a user-facing error message. + // Does not clear the session attribute — callers clear it after the full operation + // succeeds so the user can retry with the same captcha if a later step fails. + // Logging matches LoginController's RegisterUserAction. + private String verifyCaptcha(String submittedText, String emailForLogging) + { + var session = getViewContext().getRequest().getSession(true); + String expected = (String) session.getAttribute(LabKeyKaptchaServlet.SESSION_KEY_VALUE); + if (expected == null) + { + _log.info("Captcha not initialized for signup attempt"); + return "Captcha not initialized, please retry."; + } + if (!expected.equalsIgnoreCase(StringUtils.trimToNull(submittedText))) + { + _log.warn("Captcha text did not match for signup attempt for {}", emailForLogging); + return "Verification text does not match, please retry."; + } + return null; + } + + private void clearCaptcha() + { + getViewContext().getRequest().getSession(true).removeAttribute(LabKeyKaptchaServlet.SESSION_KEY_VALUE); + } + + // Returns a parsed ValidEmail, or null if the address is invalid (errors populated). + // Uses EmailValidator first because ValidEmail's constructor does not throw on bare + // strings like "foo" - it silently appends the server's default domain. + private ValidEmail parseAndValidateEmail(SignupForm form, Errors errors) + { + EmailValidator validator = EmailValidator.getInstance(); + if (!validator.isValid(form.getEmail())) + { + errors.reject(ERROR_MSG, "'" + form.getEmail() + "' is not a valid email address."); + return null; + } + try + { + return new ValidEmail(form.getEmail()); + } + catch (ValidEmail.InvalidEmailException iee) + { + errors.reject(ERROR_MSG, iee.getMessage()); + return null; + } + } + + // Creates (or reuses) a TempUser row and sends the confirmation email in a single + // transaction. On send failure the exception propagates and the transaction rolls back + // automatically so a freshly inserted TempUser row does not persist. + private void createUserAndSendEmail(SignupForm form, ValidEmail email) + throws MessagingException, ConfigurationException, java.sql.SQLException + { + try (DbScope.Transaction transaction = SignUpManager.getSchema().getScope().ensureTransaction()) + { + TempUser tempUser = getTempUser(form, email); + ActionURL confirmationUrl = getConfirmationURL(getContainer(), email, tempUser.getKey()); + User mockUser = new User(); + mockUser.setEmail(email.getEmailAddress()); + SecurityManager.sendEmail(getContainer(), mockUser, + SecurityManager.getRegistrationMessage(null, false), + email.getEmailAddress(), confirmationUrl); + transaction.commit(); + } + } + + private static List errorsToMessages(Errors errors) + { + return errors.getAllErrors().stream() + .map(ObjectError::getDefaultMessage) + .toList(); + } + + private static String sendEmailErrorMessage(Container container) + { + return "Could not send new user registration email. Please contact your server administrator at " + + LookAndFeelProperties.getInstance(container).getSystemEmailAddress(); } public static ActionURL getConfirmationURL(Container c, ValidEmail email, String key) @@ -657,6 +746,7 @@ public static class SignupForm extends ReturnUrlForm private String _lastName; private String _organization; private String _email; + private String _emailConfirm; private boolean _accountExists; private boolean _newSignUp = true; @@ -700,6 +790,16 @@ public void setEmail(String email) _email = email; } + public String getEmailConfirm() + { + return _emailConfirm; + } + + public void setEmailConfirm(String emailConfirm) + { + _emailConfirm = emailConfirm; + } + public boolean isAccountExists() { return _accountExists; @@ -719,6 +819,18 @@ public void setNewSignUp(boolean newSignUp) { _newSignUp = newSignUp; } + + private String _kaptchaText; + + public String getKaptchaText() + { + return _kaptchaText; + } + + public void setKaptchaText(String kaptchaText) + { + _kaptchaText = kaptchaText; + } } @RequiresLogin @@ -778,52 +890,56 @@ public ApiResponse execute(SignupForm signupForm, BindException errors) throws E { ApiSimpleResponse response = new ApiSimpleResponse(); - ValidEmail email; - try + String kaptchaError = verifyCaptcha(signupForm.getKaptchaText(), signupForm.getEmail()); + if (kaptchaError != null) { - email = new ValidEmail(signupForm.getEmail()); + response.put("status", "ERROR"); + response.put("error_message", List.of(kaptchaError)); + return response; } - catch (ValidEmail.InvalidEmailException iee) + + validateSignupForm(signupForm, errors); + if (errors.hasErrors()) { - errors.reject(ERROR_MSG, iee.getMessage()); + response.put("status", "ERROR"); + response.put("error_message", errorsToMessages(errors)); return response; } - if(UserManager.userExists(email)) + ValidEmail email = parseAndValidateEmail(signupForm, errors); + if (email == null) { - response.put("status", "USER_EXISTS"); + response.put("status", "ERROR"); + response.put("error_message", errorsToMessages(errors)); return response; } - validateSignupForm(signupForm, errors); - if(errors.hasErrors()) + if (UserManager.userExists(email)) { + response.put("status", "USER_EXISTS"); return response; } - TempUser tempUser = getTempUser(signupForm, email); - - - // Send email to the user. - ActionURL confirmationUrl = getConfirmationURL(getContainer(), email, tempUser.getKey()); try { - User mockUser = new User(); - mockUser.setEmail(email.getEmailAddress()); - SecurityManager.sendEmail(getContainer(), mockUser, SecurityManager.getRegistrationMessage(null, false), email.getEmailAddress(), confirmationUrl); + createUserAndSendEmail(signupForm, email); } - catch(Exception e) + catch (MessagingException | ConfigurationException e) { - String systemEmail = LookAndFeelProperties.getInstance(getContainer()).getSystemEmailAddress(); + response.put("status", "ERROR"); List messages = new ArrayList<>(); - messages.add("Could not send new user registration email. Please contact your server administrator at " + systemEmail); - messages.add(e.getMessage()); + messages.add(sendEmailErrorMessage(getContainer())); + if (e.getMessage() != null) + { + messages.add(e.getMessage()); + } response.put("error_message", messages); + return response; } - signupForm.setNewSignUp(false); // TODO: Most likely not needed here - response.put("status", "USER_ADDED"); + clearCaptcha(); + response.put("status", "USER_ADDED"); return response; } } diff --git a/signup/src/org/labkey/signup/signupPage.jsp b/signup/src/org/labkey/signup/signupPage.jsp index ddb0ae31..e97869fd 100644 --- a/signup/src/org/labkey/signup/signupPage.jsp +++ b/signup/src/org/labkey/signup/signupPage.jsp @@ -15,7 +15,6 @@ * limitations under the License. */ %> -<%@ page import="org.labkey.api.view.ActionURL" %> <%@ page import="org.labkey.api.view.HttpView" %> <%@ page import="org.labkey.signup.SignUpController.BeginAction" %> <%@ page import="org.labkey.signup.SignUpController.SignupForm" %> @@ -23,33 +22,34 @@ <%@ taglib prefix="labkey" uri="http://www.labkey.org/taglib" %> <% SignupForm form = (SignupForm)HttpView.currentModel(); - ActionURL url = urlFor(BeginAction.class); + String contextPath = request.getContextPath(); %> - -
- - - - - - - - - - - - - - - - - - - - - -
*
*
*
*
- + + + + + + + + <%-- Verification: standalone full-width block --%> +
Verification
+

Please enter the characters shown below (case-insensitive).

+

Get a new image.

+ Captcha + + +
+ +
+
+ + diff --git a/testresults/src/org/labkey/testresults/SendTestResultsEmail.java b/testresults/src/org/labkey/testresults/SendTestResultsEmail.java index 412990ac..51ce62d9 100644 --- a/testresults/src/org/labkey/testresults/SendTestResultsEmail.java +++ b/testresults/src/org/labkey/testresults/SendTestResultsEmail.java @@ -121,7 +121,7 @@ public Pair getHTMLEmail(org.labkey.api.security.User from) TestResultsController.ensureRunDataCached(runs, false); TestResultsController.populatePassesLeaksFails(runs); - User[] users = TestResultsController.getUsers(container, null); + User[] users = TestResultsController.getUsers(from, container, null); RunDownBean data = new RunDownBean(runs, users); RunProblems problems = new RunProblems(data.getRunsByDate(end)); diff --git a/testresults/src/org/labkey/testresults/TestResultsController.java b/testresults/src/org/labkey/testresults/TestResultsController.java index 1e316f97..2ea0a9dc 100644 --- a/testresults/src/org/labkey/testresults/TestResultsController.java +++ b/testresults/src/org/labkey/testresults/TestResultsController.java @@ -26,6 +26,7 @@ import org.apache.logging.log4j.Logger; import org.apache.xmlbeans.XmlException; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.labkey.api.action.ApiSimpleResponse; import org.labkey.api.action.MutatingApiAction; import org.labkey.api.action.ReadOnlyApiAction; @@ -35,6 +36,7 @@ import org.labkey.api.collections.IntHashMap; import org.labkey.api.data.CompareType; import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerFilter; import org.labkey.api.data.ContainerManager; import org.labkey.api.data.DbScope; import org.labkey.api.data.JdbcType; @@ -45,6 +47,7 @@ import org.labkey.api.data.SqlExecutor; import org.labkey.api.data.SqlSelector; import org.labkey.api.data.Table; +import org.labkey.api.data.TableInfo; import org.labkey.api.data.TableSelector; import org.labkey.api.files.FileContentService; import org.labkey.api.notification.EmailMessage; @@ -324,7 +327,7 @@ public static RunDownBean getRunDownBean(org.labkey.api.security.User user, Cont ensureRunDataCached(allRuns, false); - User[] users = getUsers(c, null); + User[] users = getUsers(user, c, null); return new RunDownBean(allRuns, users, viewType, null, endDate); } @@ -409,50 +412,61 @@ public static void ensureRunDataCached(RunDetail[] runs, boolean keepObjData) { } } - public static User[] getUsers(Container trainingDataContainer, String username) { - SQLFragment sqlFragment = new SQLFragment(); - sqlFragment.append("SELECT id, username FROM testresults.user"); + public static User[] getUsers(@NotNull org.labkey.api.security.User user, @NotNull Container trainingDataContainer, @Nullable String username) { + if (trainingDataContainer == null) + throw new IllegalArgumentException("getUsers requires a folder; use findUserIdByName(username) for the no-folder lookup"); + // Scope the computer list to the current folder via the filtered "user" query table. + TableInfo userTable = new TestResultsSchema(user, trainingDataContainer) + .getTable(TestResultsSchema.TABLE_USER, ContainerFilter.current(trainingDataContainer, user)); + SimpleFilter filter = new SimpleFilter(); if (username != null && !username.isEmpty()) - { - sqlFragment.append(" WHERE username = ?"); - sqlFragment.add(username); - } - sqlFragment.append(" ORDER BY id"); + filter.addCondition(FieldKey.fromParts("username"), username); List users = new ArrayList<>(); - new SqlSelector(TestResultsSchema.getSchema(), sqlFragment).forEach(rs -> { + new TableSelector(userTable, filter, new Sort("id")).forEach(rs -> { User u = new User(); u.setId(rs.getInt("id")); u.setUsername(rs.getString("username")); users.add(u); }); - if (trainingDataContainer != null) - { - sqlFragment = new SQLFragment(); - sqlFragment.append( - "SELECT userid, meantestsrun, meanmemory, stddevtestsrun, stddevmemory, active " + - "FROM testresults.userdata " + - "WHERE container = ?"); - sqlFragment.add(trainingDataContainer.getEntityId()); - new SqlSelector(TestResultsSchema.getSchema(), sqlFragment).forEach(rs -> { - for (User u : users) + // Attach the training stats (userdata is keyed by container). + SQLFragment sqlFragment = new SQLFragment(); + sqlFragment.append( + "SELECT userid, meantestsrun, meanmemory, stddevtestsrun, stddevmemory, active " + + "FROM testresults.userdata " + + "WHERE container = ?"); + sqlFragment.add(trainingDataContainer.getEntityId()); + new SqlSelector(TestResultsSchema.getSchema(), sqlFragment).forEach(rs -> { + for (User u : users) + { + if (u.getId() == rs.getInt("userid")) { - if (u.getId() == rs.getInt("userid")) - { - u.setMeantestsrun(rs.getDouble("meantestsrun")); - u.setMeanmemory(rs.getDouble("meanmemory")); - u.setStddevtestsrun(rs.getDouble("stddevtestsrun")); - u.setStddevmemory(rs.getDouble("stddevmemory")); - u.setContainer(trainingDataContainer); - u.setActive(rs.getBoolean("active")); - break; - } + u.setMeantestsrun(rs.getDouble("meantestsrun")); + u.setMeanmemory(rs.getDouble("meanmemory")); + u.setStddevtestsrun(rs.getDouble("stddevtestsrun")); + u.setStddevmemory(rs.getDouble("stddevmemory")); + u.setContainer(trainingDataContainer); + u.setActive(rs.getBoolean("active")); + break; } - }); - } + } + }); return users.toArray(new User[0]); } + /** + * Looks up a computer's id by exact name across all folders, or -1 if none exists. The "user" + * row is global (no container column), and run ingestion (ParseAndStoreXML) runs anonymously + * and may be the computer's first post to a given folder, so this lookup must not be container-scoped. + */ + private static int findUserIdByName(@NotNull String username) + { + SQLFragment sql = new SQLFragment("SELECT id FROM testresults.user WHERE username = ? ORDER BY id", username); + List ids = new ArrayList<>(); + new SqlSelector(TestResultsSchema.getSchema(), sql).forEach(rs -> ids.add(rs.getInt("id"))); + return ids.isEmpty() ? -1 : ids.get(0); + } + /** * action to view trainging data for each user */ @@ -475,7 +489,7 @@ public ModelAndView getView(Object o, BindException errors) throws Exception ensureRunDataCached(runs, false); - User[] users = getUsers(getContainer(), null); + User[] users = getUsers(getUser(), getContainer(), null); TestsDataBean bean = new TestsDataBean(runs, users); JspView view = new JspView<>("/org/labkey/testresults/view/trainingdata.jsp", bean); view.setFrame(WebPartView.FrameType.PORTAL); @@ -623,7 +637,7 @@ public ModelAndView getView(ShowUserForm form, BindException errors) throws Exce User user = null; if (StringUtils.isNotBlank(userName)) { - User[] users = getUsers(getContainer(), userName); + User[] users = getUsers(getUser(), getContainer(), userName); if (users.length == 1) user = users[0]; } @@ -1822,19 +1836,18 @@ private static void ParseAndStoreXML(String xml, Container c) throws Exception Element docElement = doc.getDocumentElement(); // USER ID - int userid; String username = docElement.getAttribute("id"); - User[] details = getUsers(null, username); - if (details.length == 0) { - User newUser = new User(); + if (StringUtils.isBlank(username)) + throw new IllegalArgumentException("Posted run XML is missing the required computer name (id attribute)"); + int userid = findUserIdByName(username); + if (userid == -1) { + User newUser = new User(); newUser.setUsername(username); User u = Table.insert(null, TestResultsSchema.getTableInfoUser(), newUser); if (u == null) { throw new Exception(); } userid = u.getId(); - } else { - userid = details[0].getId(); } if (userid == -1) throw new Exception("Issue with user/userid, may not be set"); diff --git a/testresults/src/org/labkey/testresults/TestResultsSchema.java b/testresults/src/org/labkey/testresults/TestResultsSchema.java index 354f7a25..6f7f45d7 100644 --- a/testresults/src/org/labkey/testresults/TestResultsSchema.java +++ b/testresults/src/org/labkey/testresults/TestResultsSchema.java @@ -24,10 +24,12 @@ import org.labkey.api.data.DbSchema; import org.labkey.api.data.DbSchemaType; import org.labkey.api.data.ForeignKey; +import org.labkey.api.data.SQLFragment; import org.labkey.api.data.TableInfo; import org.labkey.api.data.dialect.SqlDialect; import org.labkey.api.module.Module; import org.labkey.api.query.DefaultSchema; +import org.labkey.api.query.FieldKey; import org.labkey.api.query.FilteredTable; import org.labkey.api.query.QueryForeignKey; import org.labkey.api.query.QuerySchema; @@ -86,7 +88,8 @@ public static SqlDialect getSqlDialect() @Override public @Nullable TableInfo createTable(@NotNull String name, @NotNull ContainerFilter cf) { - TableInfo dbTable = switch (name.toLowerCase()) + String lower = name.toLowerCase(); + TableInfo dbTable = switch (lower) { case TABLE_TEST_RUNS -> getTableInfoTestRuns(); case TABLE_USER -> getTableInfoUser(); @@ -102,12 +105,77 @@ public static SqlDialect getSqlDialect() }; if (dbTable == null) return null; - FilteredTable table = new FilteredTable<>(dbTable, this, cf); + FilteredTable table = switch (lower) + { + case TABLE_HANGS, TABLE_MEMORY_LEAKS, TABLE_HANDLE_LEAKS, TABLE_TEST_PASSES, TABLE_TEST_FAILS -> + createRunChildTable(dbTable, cf, "testrunid"); + case TABLE_TRAIN_RUNS -> + createRunChildTable(dbTable, cf, "runid"); + case TABLE_USER -> + createUserTable(dbTable, cf); + default -> + new FilteredTable<>(dbTable, this, cf); + }; table.wrapAllColumns(true); resolveSchemaForeignKeys(table, cf); return table; } + /** + * Filters a table that has no container column of its own by joining to testruns + * via fkColumn and applying the container filter to testruns.container. + */ + private FilteredTable createRunChildTable(TableInfo dbTable, ContainerFilter cf, String fkColumn) + { + return new FilteredTable<>(dbTable, this, cf) + { + @Override + public FieldKey getContainerFieldKey() + { + return FieldKey.fromParts(fkColumn, "container"); + } + + @Override + protected void applyContainerFilter(ContainerFilter filter) + { + FieldKey containerFieldKey = FieldKey.fromParts("container"); + clearConditions(containerFieldKey); + SQLFragment sql = new SQLFragment().appendIdentifier(fkColumn).append(" IN (SELECT tr.id FROM "); + sql.append(getTableInfoTestRuns(), "tr"); + sql.append(" WHERE "); + sql.append(filter.getSQLFragment(getSchema(), new SQLFragment("tr.container"))); + sql.append(")"); + addCondition(sql, containerFieldKey); + } + }; + } + + /** + * The user table has no container column and no FK to testruns. Filter it to users + * referenced by at least one testrun in an allowed container. + * + * No getContainerFieldKey() override: a machine can post to several containers, so it + * has no single "home" container. applyContainerFilter() alone does the scoping. + */ + private FilteredTable createUserTable(TableInfo dbTable, ContainerFilter cf) + { + return new FilteredTable<>(dbTable, this, cf) + { + @Override + protected void applyContainerFilter(ContainerFilter filter) + { + FieldKey containerFieldKey = FieldKey.fromParts("container"); + clearConditions(containerFieldKey); + SQLFragment sql = new SQLFragment("id IN (SELECT tr.userid FROM "); + sql.append(getTableInfoTestRuns(), "tr"); + sql.append(" WHERE "); + sql.append(filter.getSQLFragment(getSchema(), new SQLFragment("tr.container"))); + sql.append(")"); + addCondition(sql, containerFieldKey); + } + }; + } + /** * Converts DbSchema-level FKs (propagated by wrapAllColumns()) into UserSchema-level FKs * so the Query Schema Browser renders hyperlinks and can navigate to target query grids. diff --git a/testresults/src/org/labkey/testresults/view/user.jsp b/testresults/src/org/labkey/testresults/view/user.jsp index 7d8ba472..c10300cc 100644 --- a/testresults/src/org/labkey/testresults/view/user.jsp +++ b/testresults/src/org/labkey/testresults/view/user.jsp @@ -103,7 +103,7 @@ <%-- This form is meant to parse and store xml files into the database--%> <%-- --%> <% - User[] users = TestResultsController.getUsers(null, null); + User[] users = TestResultsController.getUsers(getViewContext().getUser(), getViewContext().getContainer(), null); Arrays.sort(users, Comparator.comparing(User::getUsername)); %>