Merge branch 'develop' into biconou_develop_PR_metrics

This commit is contained in:
Rémi Cocula
2017-02-13 22:19:45 +01:00
113 changed files with 334 additions and 5736 deletions
+5
View File
@@ -68,6 +68,11 @@
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-ldap</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ldap</groupId>
<artifactId>spring-ldap-core</artifactId>
<version>2.0.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-taglibs</artifactId>
@@ -1,6 +1,9 @@
package org.libresonic.player.boot;
import net.sf.ehcache.constructs.web.ShutdownListener;
import org.apache.catalina.Container;
import org.apache.catalina.Wrapper;
import org.apache.catalina.webresources.StandardRoot;
import org.directwebremoting.servlet.DwrServlet;
import org.libresonic.player.filter.*;
import org.libresonic.player.spring.LibresonicPropertySourceConfigurer;
@@ -11,6 +14,10 @@ import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration
import org.springframework.boot.autoconfigure.jmx.JmxAutoConfiguration;
import org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.context.embedded.ConfigurableEmbeddedServletContainer;
import org.springframework.boot.context.embedded.EmbeddedServletContainerCustomizer;
import org.springframework.boot.context.embedded.tomcat.TomcatContextCustomizer;
import org.springframework.boot.context.embedded.tomcat.TomcatEmbeddedServletContainerFactory;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.boot.web.support.SpringBootServletInitializer;
@@ -32,7 +39,7 @@ import javax.servlet.ServletContextListener;
"classpath:/applicationContext-cache.xml",
"classpath:/applicationContext-sonos.xml",
"classpath:/libresonic-servlet.xml"})
public class Application extends SpringBootServletInitializer {
public class Application extends SpringBootServletInitializer implements EmbeddedServletContainerCustomizer {
/**
* Registers the DWR servlet.
@@ -177,9 +184,36 @@ public class Application extends SpringBootServletInitializer {
return doConfigure(application);
}
@Override
public void customize(ConfigurableEmbeddedServletContainer container) {
if (container instanceof TomcatEmbeddedServletContainerFactory) {
TomcatEmbeddedServletContainerFactory tomcatFactory = (TomcatEmbeddedServletContainerFactory) container;
tomcatFactory.addContextCustomizers((TomcatContextCustomizer) context -> {
// Increase the size and time before eviction of the Tomcat
// cache so that resources aren't uncompressed too often.
// See https://github.com/jhipster/generator-jhipster/issues/3995
StandardRoot resources = new StandardRoot();
resources.setCacheMaxSize(100000);
resources.setCacheObjectMaxSize(4000);
resources.setCacheTtl(24 * 3600 * 1000); // 1 day, in milliseconds
context.setResources(resources);
// Put Jasper in production mode so that JSP aren't recompiled
// on each request.
// See http://stackoverflow.com/questions/29653326/spring-boot-application-slow-because-of-jsp-compilation
Container jsp = context.findChild("jsp");
if (jsp instanceof Wrapper) {
((Wrapper)jsp).addInitParameter("development", "false");
}
});
}
}
public static void main(String[] args) {
SpringApplicationBuilder builder = new SpringApplicationBuilder();
doConfigure(builder).run(args);
}
}
}
@@ -98,6 +98,8 @@ public class AdvancedSettingsController {
settingsService.setSmtpPassword(command.getSmtpPassword());
}
settingsService.save();
return "redirect:advancedSettings.view";
}
@@ -1,35 +1,9 @@
package org.libresonic.player.controller;
import org.apache.commons.lang.ObjectUtils;
import org.libresonic.player.service.SettingsService;
import javax.servlet.http.HttpServletRequest;
/**
* This class has been created to refactor code previously present
* in the MultiController.
*/
public class ControllerUtils {
public static void updatePortAndContextPath(HttpServletRequest request, SettingsService settingsService) {
int port = Integer.parseInt(System.getProperty("libresonic.port", String.valueOf(request.getLocalPort())));
int httpsPort = Integer.parseInt(System.getProperty("libresonic.httpsPort", "0"));
String contextPath = request.getContextPath().replace("/", "");
if (settingsService.getPort() != port) {
settingsService.setPort(port);
settingsService.save();
}
if (settingsService.getHttpsPort() != httpsPort) {
settingsService.setHttpsPort(httpsPort);
settingsService.save();
}
if (!ObjectUtils.equals(settingsService.getUrlRedirectContextPath(), contextPath)) {
settingsService.setUrlRedirectContextPath(contextPath);
settingsService.save();
}
}
}
@@ -48,7 +48,7 @@ public class DBController {
@Autowired
private DaoHelper daoHelper;
@RequestMapping(method = RequestMethod.GET)
@RequestMapping(method = { RequestMethod.GET, RequestMethod.POST })
protected ModelAndView handleRequestInternal(HttpServletRequest request, HttpServletResponse response) throws Exception {
Map<String, Object> map = new HashMap<String, Object>();
@@ -41,7 +41,6 @@ public class GettingStartedController {
@RequestMapping(method = RequestMethod.GET)
public ModelAndView gettingStarted(HttpServletRequest request) {
ControllerUtils.updatePortAndContextPath(request,settingsService);
if (request.getParameter("hide") != null) {
settingsService.setGettingStartedEnabled(false);
@@ -29,7 +29,6 @@ public class IndexController {
@RequestMapping(method = { RequestMethod.GET})
public ModelAndView index(HttpServletRequest request) {
ControllerUtils.updatePortAndContextPath(request,settingsService);
UserSettings userSettings = settingsService.getUserSettings(securityService.getCurrentUsername(request));
Map<String, Object> map = new HashMap<String, Object>();
@@ -80,7 +80,7 @@ public class StatusController {
}
private static class TransferStatusHolder {
public static class TransferStatusHolder {
private TransferStatus transferStatus;
private boolean isStream;
private boolean isDownload;
@@ -69,11 +69,23 @@ public class UserDao extends AbstractDao {
* Returns the user with the given username.
*
* @param username The username used when logging in.
* @param caseSensitive
* @return The user, or <code>null</code> if not found.
*/
public User getUserByName(String username) {
String sql = "select " + USER_COLUMNS + " from " + getUserTable() + " where username=?";
User user = queryOne(sql, userRowMapper, username);
public User getUserByName(String username, boolean caseSensitive) {
String sql;
if(caseSensitive) {
sql = "select " + USER_COLUMNS + " from " + getUserTable() + " where username=?";
} else {
sql = "select " + USER_COLUMNS + " from " + getUserTable() + " where UPPER(username)=UPPER(?)";
}
List<User> users = query(sql, userRowMapper, username);
User user = null;
if(users.size() == 1) {
user = users.iterator().next();
} else if (users.size() > 1) {
throw new RuntimeException("Too many matching users");
}
if(user != null) {
readRoles(user);
}
@@ -0,0 +1,133 @@
/*
* Copyright 2004, 2005, 2006 Acegi Technology Pty Limited
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.libresonic.player.security;
import org.libresonic.player.Logger;
import org.libresonic.player.domain.User;
import org.libresonic.player.service.SecurityService;
import org.libresonic.player.service.SettingsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.ldap.core.DirContextAdapter;
import org.springframework.ldap.core.DirContextOperations;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.ldap.ppolicy.PasswordPolicyControl;
import org.springframework.security.ldap.ppolicy.PasswordPolicyResponseControl;
import org.springframework.security.ldap.userdetails.LdapUserDetailsImpl;
import org.springframework.security.ldap.userdetails.UserDetailsContextMapper;
import org.springframework.stereotype.Component;
import java.util.Collection;
@Component
public class LibresonicUserDetailsContextMapper implements UserDetailsContextMapper {
// ~ Instance fields
// ================================================================================================
private final Logger logger = Logger.getLogger(LibresonicUserDetailsContextMapper.class);
private String passwordAttributeName = "userPassword";
@Autowired
SecurityService securityService;
@Autowired
SettingsService settingsService;
// ~ Methods
// ========================================================================================================
public UserDetails mapUserFromContext(DirContextOperations ctx, String username,
Collection<? extends GrantedAuthority> authorities) {
String dn = ctx.getNameInNamespace();
logger.debug("Mapping user details from context with DN: " + dn);
// User must be defined in Libresonic, unless auto-shadowing is enabled.
User user = securityService.getUserByName(username, false);
if (user == null && !settingsService.isLdapAutoShadowing()) {
throw new BadCredentialsException("User does not exist.");
}
if (user == null) {
User newUser = new User(username, "", null, true, 0L, 0L, 0L);
newUser.setStreamRole(true);
newUser.setSettingsRole(true);
securityService.createUser(newUser);
logger.info("Created local user '" + username + "' for DN " + dn);
user = securityService.getUserByName(username, false);
}
// LDAP authentication must be enabled for the given user.
if (!user.isLdapAuthenticated()) {
throw new BadCredentialsException("LDAP authentication disabled for user.");
}
LdapUserDetailsImpl.Essence essence = new LdapUserDetailsImpl.Essence();
essence.setDn(dn);
Object passwordValue = ctx.getObjectAttribute(passwordAttributeName);
if (passwordValue != null) {
essence.setPassword(mapPassword(passwordValue));
}
essence.setUsername(user.getUsername());
// Add the supplied authorities
for (GrantedAuthority authority : securityService.getGrantedAuthorities(user.getUsername())) {
essence.addAuthority(authority);
}
// Check for PPolicy data
PasswordPolicyResponseControl ppolicy = (PasswordPolicyResponseControl) ctx
.getObjectAttribute(PasswordPolicyControl.OID);
if (ppolicy != null) {
essence.setTimeBeforeExpiration(ppolicy.getTimeBeforeExpiration());
essence.setGraceLoginsRemaining(ppolicy.getGraceLoginsRemaining());
}
return essence.createUserDetails();
}
public void mapUserToContext(UserDetails user, DirContextAdapter ctx) {
throw new UnsupportedOperationException(
"LdapUserDetailsMapper only supports reading from a context. Please"
+ "use a subclass if mapUserToContext() is required.");
}
/**
* Extension point to allow customized creation of the user's password from the
* attribute stored in the directory.
*
* @param passwordValue the value of the password attribute
* @return a String representation of the password.
*/
protected String mapPassword(Object passwordValue) {
if (!(passwordValue instanceof String)) {
// Assume it's binary
passwordValue = new String((byte[]) passwordValue);
}
return (String) passwordValue;
}
}
@@ -1,6 +1,7 @@
package org.libresonic.player.security;
import org.libresonic.player.service.SecurityService;
import org.libresonic.player.service.SettingsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -18,20 +19,37 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private SecurityService securityService;
@Autowired
private CsrfSecurityRequestMatcher csrfSecurityRequestMatcher;
@Autowired
LoginFailureLogger loginFailureLogger;
@Autowired
SettingsService settingsService;
@Autowired
LibresonicUserDetailsContextMapper libresonicUserDetailsContextMapper;
@Override
@Bean(name = "authenticationManager")
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
if (settingsService.isLdapEnabled()) {
auth.ldapAuthentication()
.contextSource()
.managerDn(settingsService.getLdapManagerDn())
.managerPassword(settingsService.getLdapManagerPassword())
.url(settingsService.getLdapUrl())
.and()
.userSearchFilter(settingsService.getLdapSearchFilter())
.userDetailsContextMapper(libresonicUserDetailsContextMapper);
}
auth.userDetailsService(securityService);
}
@@ -89,7 +107,6 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
.passwordParameter("j_password")
// see http://docs.spring.io/spring-security/site/docs/3.2.4.RELEASE/reference/htmlsingle/#csrf-logout
.and().logout().logoutRequestMatcher(new AntPathRequestMatcher("/logout", "GET")).logoutSuccessUrl("/login?logout")
.and().rememberMe().userDetailsService(securityService).key("libresonic");
.and().rememberMe().key("libresonic");
}
}
@@ -461,8 +461,7 @@ public class PlaylistService {
for (Object obj : tracks) {
Element track = (Element) obj;
String location = track.getChildText("location", ns);
if (location != null && location.startsWith("file://")) {
location = location.replaceFirst("file://", "");
if (location != null) {
MediaFile file = getMediaFile(location);
if (file != null) {
ok.add(file);
@@ -62,11 +62,22 @@ public class SecurityService implements UserDetailsService {
* @throws DataAccessException If user could not be found for a repository-specific reason.
*/
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException, DataAccessException {
User user = getUserByName(username);
return loadUserByUsername(username, true);
}
public UserDetails loadUserByUsername(String username, boolean caseSensitive)
throws UsernameNotFoundException, DataAccessException {
User user = getUserByName(username, caseSensitive);
if (user == null) {
throw new UsernameNotFoundException("User \"" + username + "\" was not found.");
}
List<GrantedAuthority> authorities = getGrantedAuthorities(username);
return new org.springframework.security.core.userdetails.User(username, user.getPassword(), authorities);
}
public List<GrantedAuthority> getGrantedAuthorities(String username) {
String[] roles = userDao.getRolesForUser(username);
List<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("IS_AUTHENTICATED_ANONYMOUSLY"));
@@ -74,8 +85,7 @@ public class SecurityService implements UserDetailsService {
for (int i = 0; i < roles.length; i++) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + roles[i].toUpperCase()));
}
return new org.springframework.security.core.userdetails.User(username, user.getPassword(), authorities);
return authorities;
}
/**
@@ -86,7 +96,7 @@ public class SecurityService implements UserDetailsService {
*/
public User getCurrentUser(HttpServletRequest request) {
String username = getCurrentUsername(request);
return username == null ? null : userDao.getUserByName(username);
return username == null ? null : getUserByName(username);
}
/**
@@ -106,7 +116,17 @@ public class SecurityService implements UserDetailsService {
* @return The user, or <code>null</code> if not found.
*/
public User getUserByName(String username) {
return userDao.getUserByName(username);
return getUserByName(username, true);
}
/**
* Returns the user with the given username
* @param username
* @param caseSensitive If false, will do a case insensitive search
* @return
*/
public User getUserByName(String username, boolean caseSensitive) {
return userDao.getUserByName(username, caseSensitive);
}
/**
@@ -371,6 +371,7 @@ advancedsettings.ldapsearchfilter = LDAP search filter
advancedsettings.ldapmanagerdn = LDAP manager DN<br><div class="detail">(Optional)</div>
advancedsettings.ldapmanagerpassword = Password
advancedsettings.ldapautoshadowing = Automatically create users in {0}
advancedsettings.ldapRequiresRestart = LDAP settings require a restart to take effect
advancedsettings.smtpPort = SMTP port
advancedsettings.smtpServer = SMTP server
advancedsettings.smtpEncryption = SMTP encryption
@@ -138,6 +138,8 @@
</tr>
</table>
<p class="warning"><fmt:message key="advancedsettings.ldapRequiresRestart"/></p>
<input type="submit" value="<fmt:message key="common.save"/>" style="margin-right:0.3em">
<input type="button" value="<fmt:message key="common.cancel"/>" onclick="location.href='nowPlaying.view'">
@@ -278,6 +278,7 @@
<div id="commentForm" style="display:none">
<form method="post" action="setMusicFileInfo.view">
<sec:csrfInput />
<input type="hidden" name="action" value="comment">
<input type="hidden" name="id" value="${model.dir.id}">
<textarea name="comment" rows="6" cols="70">${model.dir.comment}</textarea>
@@ -208,6 +208,7 @@
<div id="commentForm" style="display:none">
<form method="post" action="setMusicFileInfo.view">
<sec:csrfInput />
<input type="hidden" name="action" value="comment">
<input type="hidden" name="id" value="${model.dir.id}">
<textarea name="comment" rows="6" cols="70">${model.dir.comment}</textarea>
@@ -91,6 +91,7 @@
<body class="mainframe bgcolor1" onload="search()">
<h1><fmt:message key="changecoverart.title"/></h1>
<form action="javascript:search()">
<sec:csrfInput />
<table class="indent"><tr>
<td><input id="artist" name="artist" placeholder="<fmt:message key="changecoverart.artist"/>" size="35" type="text" value="${model.artist}" onclick="select()"/></td>
<td><input id="album" name="album" placeholder="<fmt:message key="changecoverart.album"/>" size="35" type="text" value="${model.album}" onclick="select()"/></td>
@@ -99,6 +100,7 @@
</form>
<form action="javascript:setImage(dwr.util.getValue('url'))">
<sec:csrfInput />
<table><tr>
<td><label for="url"><fmt:message key="changecoverart.address"/></label></td>
<td style="padding-left:0.5em"><input type="text" name="url" size="50" id="url" value="http://" onclick="select()"/></td>
@@ -7,6 +7,7 @@
<h1>Database query</h1>
<form method="post" action="db.view">
<sec:csrfInput />
<textarea rows="10" cols="80" id="query" name="query" style="margin-top:1em">${model.query}</textarea>
<input type="submit" value="<fmt:message key="common.ok"/>">
</form>
@@ -34,6 +34,7 @@
</c:import>
<form method="post" action="dlnaSettings.view">
<sec:csrfInput />
<div>
<input type="checkbox" name="dlnaEnabled" id="dlnaEnabled" class="checkbox"
@@ -31,6 +31,16 @@
%>
<table class="ruleTable indent">
<tr><td class="ruleTableHeader">Status</td>
<td class="ruleTableCell"><c:out value="${status}" /></td></tr>
<tr><td class="ruleTableHeader">Error</td>
<td class="ruleTableCell"><c:out value="${error}" /></td></tr>
<tr><td class="ruleTableHeader">Message</td>
<td class="ruleTableCell"><c:out value="${message}" /></td></tr>
<tr><td class="ruleTableHeader">Path</td>
<td class="ruleTableCell"><c:out value="${path}" /></td></tr>
<tr><td class="ruleTableHeader">Time</td>
<td class="ruleTableCell"><c:out value="${timestamp}" /></td></tr>
<tr><td class="ruleTableHeader">Exception</td>
<td class="ruleTableCell"><c:out value="${exception}" /></td></tr>
<tr><td class="ruleTableHeader">Java version</td>
@@ -30,6 +30,7 @@
<fmt:message key="importPlaylist.text"/>
</div>
<form method="post" enctype="multipart/form-data" action="importPlaylist.view">
<sec:csrfInput />
<input type="file" id="file" name="file" size="40"/>
<input type="submit" value="<fmt:message key="common.ok"/>"/>
</form>
@@ -12,6 +12,7 @@
</c:import>
<form method="post" action="internetRadioSettings.view">
<sec:csrfInput />
<table class="indent">
<tr>
<th><fmt:message key="internetradiosettings.name"/></th>
@@ -62,6 +62,7 @@
} catch(e) { return; }
elements = form.getElementsByTagName("input");
for (var i = 0; i < elements.length; i++) {
if (elements[i].type == "hidden") continue;
if (elements[i].type == "submit") continue;
if (data[elements[i].name]) elements[i].value = data[elements[i].name];
}
@@ -87,9 +88,17 @@
var data = {}
var elements = [];
elements = form.getElementsByTagName("input");
for (var i = 0; i < elements.length; i++) data[elements[i].name] = elements[i].value;
for (var i = 0; i < elements.length; i++) {
if (elements[i].type == "hidden") continue;
if (elements[i].type == "submit") continue;
data[elements[i].name] = elements[i].value;
}
elements = form.getElementsByTagName("select");
for (var i = 0; i < elements.length; i++) data[elements[i].name] = elements[i].value;
for (var i = 0; i < elements.length; i++) {
if (elements[i].type == "hidden") continue;
if (elements[i].type == "submit") continue;
data[elements[i].name] = elements[i].value;
}
localStorage.setItem("randomPlayQueue", JSON.stringify(data));
}
@@ -122,6 +131,7 @@
</h2>
<form id="randomPlayQueue" method="post" action="randomPlayQueue.view?">
<sec:csrfInput />
<table>
<tr>
<td><fmt:message key="more.random.text"/></td>
@@ -291,6 +301,7 @@
</h2>
<form method="post" enctype="multipart/form-data" action="upload.view">
<sec:csrfInput />
<table>
<tr>
<td><fmt:message key="more.upload.source"/></td>
@@ -241,6 +241,7 @@
</form:form>
<form method="post" enctype="multipart/form-data" action="avatarUpload.view">
<sec:csrfInput />
<table>
<tr>
<td style="padding-right:1em"><fmt:message key="personalsettings.avatar.changecustom"/></td>
@@ -8,6 +8,7 @@
<body class="mainframe bgcolor1" onload="document.getElementById('usernameOrEmail').focus()">
<form action="recover.view" method="POST">
<sec:csrfInput />
<div class="bgcolor2 shadow" style="padding:20px 50px 20px 50px; margin-top:100px;margin-left:50px;margin-right:50px">
<div style="margin-left: auto; margin-right: auto; width: 45em">
@@ -14,6 +14,7 @@
</c:import>
<form method="post" action="shareSettings.view">
<sec:csrfInput />
<table class="music indent">
<tr>
@@ -34,6 +34,7 @@
</c:import>
<form method="post" action="sonosSettings.view">
<sec:csrfInput />
<div>
<input type="checkbox" name="sonosEnabled" id="sonosEnabled" class="checkbox"
@@ -125,6 +125,7 @@
<td style="padding-left:1em">
<form method="post" action="search.view" target="main" name="searchForm">
<sec:csrfInput />
<td><input type="text" name="query" id="query" size="28" placeholder="${search}" onclick="select();"
onkeyup="triggerInstantSearch();"></td>
<td><a href="javascript:document.searchForm.submit()"><img src="<spring:theme code="searchImage"/>" alt="${search}" title="${search}"></a></td>
@@ -14,6 +14,7 @@
</c:import>
<form method="post" action="transcodingSettings.view">
<sec:csrfInput />
<table class="indent">
<tr>
<th><fmt:message key="transcodingsettings.name"/></th>
@@ -115,15 +115,15 @@ public class UserDaoTestCase extends DaoTestCaseBean2 {
User user = new User("sindre", "secret", null);
userDao.createUser(user);
User newUser = userDao.getUserByName("sindre");
User newUser = userDao.getUserByName("sindre", true);
assertNotNull("Error in getUserByName().", newUser);
assertUserEquals(user, newUser);
assertNull("Error in getUserByName().", userDao.getUserByName("sindre2"));
assertNull("Error in getUserByName().", userDao.getUserByName("sindre "));
assertNull("Error in getUserByName().", userDao.getUserByName("bente"));
assertNull("Error in getUserByName().", userDao.getUserByName(""));
assertNull("Error in getUserByName().", userDao.getUserByName(null));
assertNull("Error in getUserByName().", userDao.getUserByName("sindre2", true));
assertNull("Error in getUserByName().", userDao.getUserByName("sindre ", true));
assertNull("Error in getUserByName().", userDao.getUserByName("bente", true));
assertNull("Error in getUserByName().", userDao.getUserByName("", true));
assertNull("Error in getUserByName().", userDao.getUserByName(null, true));
}
@Test